تتعلّم أساسيات صناعة لعبة ثنائية الأبعاد: حلقة اللعبة و delta، اختيار المحرك بين Godot و Unity و canvas، الحركة والتصادم، الحفظ، الواجهة والنص العربي، الصوت، تصميم الصعوبة واختبار اللعب — وتخرج بلعبة مصغّرة منشورة على itch.io.
كيف تدرس هذا المسار؟
حمّل Godot المجاني وافتح المشروع التجريبي.
اقرأ كل درس وطبّق الكود في المحرر.
نفّذ المهمة — كل درس يقربك من لعبتك.
أكمل الاختبار — التقدّم يُحفظ على جهازك.
مناسب من سن 13 فما فوق، وفي آخر كل درس روابط لمراجعه الرسمية.
1. حلقة اللعبة — كيف «تفكر» اللعبة كل ثانية
الهدف: تفهم دورة الإدخال والتحديث والرسم ودور delta، وتكتب حلقة بسيطة قبل فتح أي محرك.
كل لعبة فيديو، من لعبة ألغاز بسيطة إلى لعبة منصات سريعة، برنامج يختلف عن تطبيقات النماذج والحاسبات في نقطة جوهرية: تطبيق الحاسبة ينتظر نقرة ثم يتصرف، أما اللعبة فتواصل العمل حتى لو لم يلمس اللاعب شيئاً؛ الأعداء يتحركون، والمؤقت ينقص، والسحب تنساب في الخلفية. ما يجعل ذلك ممكناً هو حلقة اللعبة (Game Loop): دورة تتكرر عشرات المرات في الثانية، وكل دورة تنتج صورة واحدة على الشاشة تسمى الإطار (Frame).
في كل دورة ثلاث مراحل بالترتيب نفسه. الأولى قراءة الإدخال (Input): أي زر مضغوط الآن؟ وأين إصبع اللاعب على الشاشة؟ الثانية التحديث (Update): نحسب الموقع الجديد لكل شيء، ونفحص التصادمات، وننقص الوقت المتبقي. الثالثة الرسم (Render): نمسح الإطار القديم ونرسم العالم بحالته الجديدة. عدد الدورات في الثانية هو معدل الإطارات (FPS)، والرقم 60 هدف شائع لأنه يطابق معدل تحديث كثير من الشاشات.
لترى الحلقة عارية بلا محرك، أنشئ ملف HTML فيه عنصر canvas بالمعرّف game، وضع فيه هذا السكربت:
const canvas = document.getElementById("game");
const ctx = canvas.getContext("2d");
let x = 0;
let last = performance.now();
function frame(now) {
const dt = (now - last) / 1000;
last = now;
x += 120 * dt;
if (x > canvas.width) x = -20;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillRect(x, 100, 20, 20);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
الدالة requestAnimationFrame تطلب من المتصفح استدعاء دالتنا قبل إعادة رسم الشاشة التالية، وتمرر لها الوقت الحالي بالمللي ثانية، وغالباً يطابق تكرارها معدل تحديث الشاشة. نطرح الوقت السابق من الحالي ونقسم على 1000 فنحصل على dt بالثواني، أي الزمن الذي مضى منذ الإطار السابق. هذا المتغير هو ما تسميه المحركات delta.
لماذا نحتاجه أصلاً؟ لو كتبت «حرّك المربع 5 بكسلات في كل إطار» فعلى شاشة 60 هرتز يقطع 300 بكسل في الثانية، وعلى شاشة 144 هرتز يقطع 720، فتتغير صعوبة اللعبة حسب جهاز اللاعب. الحل أن تكتب السرعة بوحدة «بكسل في الثانية» وتضربها في dt. مثال محسوب بالسرعة 120 في الكود أعلاه: عند 60 إطاراً تكون dt نحو 0.0167 فيتحرك المربع نحو 2 بكسل في الإطار، وعند 144 إطاراً تكون dt نحو 0.0069 فيتحرك نحو 0.83 بكسل. النتيجة في الحالتين 120 بكسل في الثانية.
عطل حقيقي ستقابله: يذكر توثيق MDN أن معظم المتصفحات توقف استدعاءات requestAnimationFrame في التبويبات غير الظاهرة. إذا عاد اللاعب بعد عشرين ثانية فإن أول dt يساوي عشرين ثانية كاملة، فيقفز المربع مسافة هائلة دفعة واحدة، وفي لعبة حقيقية قد يعبر اللاعب جداراً أو يسقط خارج الخريطة. العلاج سطر واحد يضع سقفاً لقيمة dt:
const dt = Math.min((now - last) / 1000, 0.1);
بهذا السقف تتباطأ اللعبة لحظة عند العودة بدل أن تنكسر.
المحركات تكتب هذه الحلقة نيابة عنك وتعطيك نقاط دخول إليها. في Godot تكتب دالة _process(delta) فيستدعيها المحرك مع كل إطار مرسوم، ودالة _physics_process(delta) فيستدعيها بمعدل ثابت هو 60 مرة في الثانية افتراضياً مهما كان معدل الإطارات. وفي Unity المقابل هو Update() مع القيمة Time.deltaTime، و FixedUpdate() للفيزياء. المثال نفسه في Godot 4 يصبح ثلاثة أسطر:
قاعدة القرار بين النوعين: كل ما يحرك جسماً يصطدم بغيره أو يتأثر بالجاذبية يوضع في الخطوة الثابتة، لأن محرك الفيزياء يحتاج خطوات زمنية منتظمة؛ الخطوة الطويلة المفاجئة قد تجعل جسماً سريعاً يعبر جداراً رقيقاً دون أن يُكتشف التصادم، وهي مشكلة تسمى النفق (Tunneling). أما تحديث النصوص والمؤثرات البصرية وحركة الخلفية فمكانها الدالة التي تعمل مع كل إطار.
تنبيه قبل البحث في الإنترنت: أمثلة هذا المسار مكتوبة لـ Godot 4، وكثير من الشروح القديمة تخص Godot 3 حيث تختلف أسماء عقد ودوال مهمة، فتحقق من رقم الإصدار في أي شرح قبل نسخ الكود منه.
حلقة اللعبة تكرر ثلاث مراحل بلا توقف: قراءة الإدخال، ثم تحديث الحالة، ثم رسم الإطار.
اكتب السرعة بوحدة بكسل في الثانية واضربها في delta كي تتساوى الحركة على كل الشاشات.
ضع سقفاً لقيمة dt في حلقة المتصفح حتى لا يقفز اللاعب بعد العودة من تبويب آخر.
الحركة والتصادم في الخطوة الثابتة، والنصوص والمؤثرات في خطوة كل إطار.
الهيكل
[Input]
|
v
[Update] ← speed * delta
|
v
[Render] → الشاشة
|
└── (تكرار كل إطار)
مختبر: حلقة في المتصفح
شغّل مثال canvas، ثم احذف الضرب في dt واكتب x += 2 — قارن السرعة على شاشتين مختلفتين إن أمكن، ثم أضف سقف dt وجرّب التنقل بين التبويبات.
مهمة عملية
عدّل مثال canvas ليتحرك المربع عمودياً بسرعة 90 بكسل في الثانية ويعود من الأعلى عند خروجه من الأسفل، مع سقف لقيمة dt. تحقق: يقطع المربع ارتفاع canvas بطول 450 بكسل في نحو 5 ثوانٍ.
اختبر فهمك (1/3)
حلقة اللعبة (Game Loop) تعني أساساً...
اختبر فهمك (2/3)
عاد لاعب إلى تبويب لعبة canvas بعد دقيقة، فقفزت الشخصية عبر الجدار. ما الإصلاح المناسب؟
2. المحرك والمشاهد والرسوم — من الاختيار إلى أول Sprite2D
الهدف: تختار أداة مناسبة لمشروعك الأول، وتفهم المشاهد والعقد، وتعرض شخصية متحركة في Godot.
قبل تثبيت أي برنامج، اختر الأداة التي تناسب هدفك، لأن تغييرها في منتصف المشروع يعني إعادة بناء معظم ما صنعت. للمبتدئ في الألعاب ثنائية الأبعاد ثلاثة طرق شائعة، ولكل منها مقايضات واضحة.
Godot محرك مجاني ومفتوح المصدر بترخيص MIT، وتنص صفحة الترخيص الرسمية على أن شروط المحرك لا تنطبق على المحتوى الذي تصنعه به، فلعبتك ملكك بلا رسوم ولا نسبة من الأرباح. لغته الأساسية GDScript القريبة من Python، ويدعم C# عبر نسخة .NET. نقطة يجب معرفتها مبكراً: التوثيق الرسمي ينص على أن مشاريع C# في Godot 4 لا يمكن تصديرها للويب حالياً، وأن تصدير الويب يعمل بطريقة الرسم Compatibility فقط. إن كان هدفك لعبة متصفح فابدأ بـ GDScript.
Unity محرك تجاري واسع الانتشار بلغة C#، وله مكتبة أصول ضخمة ودروس رسمية على Unity Learn، ويطلبه كثير من الاستوديوهات. خطة Personal مجانية لمن لا تتجاوز إيراداته وتمويله سقفاً تحدده Unity؛ صفحة تحديثات الأسعار الرسمية تذكر 200 ألف دولار سنوياً وقت كتابة الدرس، وتذكر أن رسوم وقت التشغيل (Runtime Fee) التي أُعلنت سابقاً أُلغيت في سبتمبر 2024. هذه الشروط تغيّرت أكثر من مرة، فراجع الصفحة بنفسك قبل أن تبني عليها قراراً تجارياً.
أما canvas مع JavaScript فلا يحتاج تثبيت شيء: متصفح ومحرر نصوص فقط. تكتب الحلقة والتصادم والصوت بيدك، وهذا أبطأ لكنه يكشف ما تخفيه المحركات، ويناسب لعبة صغيرة تُضمَّن داخل موقع. قاعدة عملية للاختيار: لعبة ويب سريعة النشر بلغة سهلة ترجّح Godot مع GDScript؛ هدف وظيفي في استوديو يعمل بـ C# أو مشروع ثلاثي الأبعاد للهاتف يرجّح Unity؛ الرغبة في فهم الأساسيات أو لعبة صغيرة داخل صفحة ويب ترجّح canvas. يكمل هذا المسار بـ Godot، مع مقارنات canvas حين تفيد.
الأدوات الثلاث تنظم العالم بالفكرة نفسها تحت أسماء مختلفة. في Godot تبني اللعبة من عقد (Nodes) لكل منها وظيفة واحدة، مرتبة في شجرة أب وأبناء، وتحفظ أي فرع منها كمشهد (Scene) بامتداد .tscn تعيد استخدامه كقالب. في Unity الكائن الأساسي هو GameObject، وهو حاوية فارغة تضيف إليها مكونات (Components) مثل SpriteRenderer للصورة و Rigidbody2D للفيزياء وسكربتاتك، والقالب القابل لإعادة الاستخدام يسمى Prefab. وفي canvas يكون القالب عادة صنفاً (class) في JavaScript تنشئ منه نسخاً تحفظها في مصفوفة.
أول مشهد في Godot: من مدير المشاريع اختر New Project، وفي خانة Renderer اختر Compatibility لأننا سننشر على الويب، ثم Create & Edit. أنشئ 2D Scene وسمِّ الجذر Player، وأضف عقدة Sprite2D، واسحب صورة PNG بخلفية شفافة إلى خاصية Texture في لوحة Inspector، ثم احفظ باسم player.tscn واضغط F6 لتشغيل المشهد وحده. مسارات الملفات داخل المشروع تبدأ بـ res://، مثل res://art/player.png، وعند وضع صورة في المجلد يستوردها المحرك تلقائياً وينشئ بجانبها ملف .import لا تحذفه.
الصورة الثابتة لا تكفي لشخصية تمشي. استبدل Sprite2D بعقدة AnimatedSprite2D، ومن خاصية Sprite Frames أنشئ مورداً جديداً، وأضف حركتين باسم idle و walk، وحمّل إطارات كل حركة من ورقة رسوم (Sprite Sheet)، واضبط سرعتها على 8 إطارات في الثانية مثلاً. ثم اختر الحركة من الكود حسب سرعة الشخصية، وسيأتي كود الحركة نفسه في الدرس التالي:
الخاصية flip_h تقلب الصورة أفقياً، فتكفيك رسوم تنظر إلى اليمين وتقلبها حين تتحرك الشخصية يساراً بدل رسم مجموعة ثانية من الإطارات.
إن ظهرت رسوم البكسل (Pixel Art) ضبابية عند التكبير فالسبب أن المحرك ينعّم الصور افتراضياً؛ غيّر Default Texture Filter إلى Nearest من إعدادات المشروع في قسم Rendering ثم Textures. وسجّل مصدر كل صورة ورخصتها من اليوم الأول: حزم kenney.nl برخصة CC0 لا تشترط ذكر المصدر، أما رخصة CC-BY فتشترط ذكر اسم الفنان، وستحتاج هذه القائمة عند النشر.
Godot مجاني بترخيص MIT ولعبتك ملكك، وتصدير الويب فيه يعمل مع GDScript وطريقة الرسم Compatibility.
خطة Unity Personal المجانية مرتبطة بسقف إيرادات وتمويل تحدده Unity، فراجع الشروط الحالية.
المشهد في Godot والـ Prefab في Unity والصنف في JavaScript كلها قوالب قابلة لإعادة الاستخدام.
AnimatedSprite2D مع flip_h يعطيك شخصية تمشي في الاتجاهين بمجموعة رسوم واحدة.
مخطط
Godot: Scene (.tscn)←شجرة Nodes
Unity: Prefab←GameObject + Components
canvas: class←نسخ في مصفوفة
Player
↓
AnimatedSprite2D→idle / walk
مختبر: شخصية تمشي
أنشئ player.tscn بعقدة AnimatedSprite2D وحركتين idle و walk — شغّل المشهد بـ F6، وغيّر سرعة الحركة بين 4 و 12 إطاراً في الثانية: أيهما أنسب لشخصيتك؟
مهمة عملية
ثبّت Godot 4، أنشئ مشروعاً بطريقة الرسم Compatibility، واعرض شخصية بحركتين idle و walk. تحقق: تشغيل المشهد يعرض الحركة دون تحذيرات صفراء في شجرة المشهد.
اختبر فهمك (1/3)
تريد لعبة متصفح مكتوبة بلغة C# في Godot 4. ما المشكلة حسب التوثيق الرسمي؟
اختبر فهمك (2/3)
ما المقابل الأقرب في Unity لـ«المشهد» القابل لإعادة الاستخدام في Godot؟
اختبر فهمك (3/3)
ظهرت رسوم Pixel Art ضبابية عند التكبير في Godot. ما الحل؟
الهدف: تبني شخصية تمشي وتقفز بالجاذبية باستخدام CharacterBody2D، وتقرأ المفاتيح بطريقة تعمل على أي لوحة.
الحركة في الألعاب سلسلة من ثلاث حلقات: اللاعب يضغط، والكود يترجم الضغطة إلى سرعة، ومحرك الفيزياء يحرك الجسم ويوقفه عند العوائق. سنبني هذه السلسلة خطوة بخطوة لشخصية منصات (Platformer) تمشي وتقفز، ثم نراجع سجل أعطال شائعة عند المبتدئين.
الخطوة الأولى: الأفعال بدل المفاتيح. من Project Settings افتح تبويب Input Map وأضف ثلاثة أفعال: move_left و move_right و jump. اربط بكل فعل أكثر من مدخل، مثل المفتاح A والسهم الأيسر وعصا يد التحكم لفعل move_left. الكود سيسأل «هل الفعل jump مضغوط؟» دون أن يعرف أي زر هو، فتستطيع لاحقاً تغيير التحكم من مكان واحد. عند إضافة مفتاح يعرض Godot 4 خيار المفتاح الفيزيائي (Physical Keycode)، أي موضع الزر على اللوحة بغض النظر عن لغتها؛ اختره لمفاتيح WASD كي تعمل عند لاعب لوحته مضبوطة على العربية.
الخطوة الثانية: نوع الجسم. CharacterBody2D للاعب والأعداء لأنك تتحكم في سرعته بالكود، و RigidBody2D لصندوق تتركه للفيزياء يسقط ويتدحرج، و StaticBody2D للأرضيات والجدران الثابتة. ضع تحت الجسم عقدة AnimatedSprite2D من الدرس السابق، وعقدة CollisionShape2D بشكل يقارب حجم الصورة؛ بدون الشكل يظهر مثلث تحذير أصفر بجانب العقدة ولن يصطدم الجسم بشيء.
الخطوة الثالثة: السكربت.
extends CharacterBody2D
const SPEED = 220.0
const JUMP_VELOCITY = -420.0
var gravity = ProjectSettings.get_setting("physics/2d/default_gravity")
func _physics_process(delta):
if not is_on_floor():
velocity.y += gravity * delta
if Input.is_action_just_pressed("jump") and is_on_floor():
velocity.y = JUMP_VELOCITY
var dir = Input.get_axis("move_left", "move_right")
velocity.x = dir * SPEED
move_and_slide()
افهم كل سطر: محور y في Godot 2D يتجه إلى الأسفل، لذلك سرعة القفز سالبة. الجاذبية تسارع، فنضيفها إلى السرعة مضروبة في delta، وقيمتها الافتراضية في إعدادات المشروع 980 بكسل في الثانية المربعة. الدالة get_axis تعيد -1 أو 0 أو 1 حسب الفعلين المضغوطين. أما move_and_slide() فتستخدم delta داخلياً، لذلك لا نضرب velocity فيها قبل الاستدعاء.
حساب سريع يغنيك عن التخمين: بسرعة قفز 420 وجاذبية 980 يصل اللاعب إلى القمة بعد 420 ÷ 980، أي نحو 0.43 ثانية، وارتفاع القفزة يساوي مربع السرعة مقسوماً على ضعف الجاذبية، أي 176400 ÷ 1960 = 90 بكسل. إن أردت قفزة بارتفاع 180 بكسل فمضاعفة السرعة تعطيك أربعة أضعاف الارتفاع، لأن الارتفاع يتناسب مع مربعها؛ اضرب السرعة في الجذر التربيعي لـ 2، أي اجعلها نحو 594.
تحسين يشعر به اللاعب دون أن يراه: زمن الرحمة (Coyote Time). كثير من اللاعبين يضغطون القفز بعد أن تغادر الشخصية حافة المنصة بجزء صغير من الثانية، فيسقطون ويشعرون بأن التحكم ظالم. الحل أن تحتفظ بمتغير يسجل آخر لحظة كانت فيها الشخصية على الأرض، وتسمح بالقفز إن لم يمضِ عليها أكثر من عُشر ثانية تقريباً. والفكرة المعكوسة تسمى تخزين الضغطة (Jump Buffer): إن ضغط اللاعب القفز قبل لمس الأرض بلحظة، نفّذه فور الهبوط.
المنطق نفسه في canvas مع JavaScript، حيث تقرأ المفاتيح وتطبق الجاذبية بنفسك داخل دالة الإطار من الدرس الأول:
const keys = new Set();
addEventListener("keydown", e => keys.add(e.code));
addEventListener("keyup", e => keys.delete(e.code));
function updatePlayer(p, dt) {
const dir = (keys.has("KeyD") ? 1 : 0) - (keys.has("KeyA") ? 1 : 0);
p.vx = dir * 220;
p.vy += 980 * dt;
p.x += p.vx * dt;
p.y += p.vy * dt;
}
نستخدم e.code لأنها تصف موضع الزر الفعلي، فالقيمة KeyA تبقى كما هي حتى لو كانت لوحة اللاعب عربية، بينما e.key تعيد الحرف المكتوب، أي «ش» على اللوحة العربية. وفي canvas لا يوجد ما يوقف اللاعب عند الأرض، فستضيف شرطاً يعيده إلى سطحها ويصفّر vy، وهذا موضوع الدرس التالي.
سجل أعطال من مشاريع المبتدئين:
1) اللاعب يزحف ببطء شديد مهما رفعت SPEED: ضُربت velocity في delta قبل move_and_slide، فاحذف الضرب.
2) القفز يتكرر ما دام الزر مضغوطاً: استُخدم is_action_pressed بدل is_action_just_pressed.
3) الحركة القطرية أسرع في لعبة منظور علوي: المتجه (1، 1) طوله نحو 1.41؛ استخدم get_vector التي تعيد متجهاً لا يتجاوز طوله 1، أو استدعِ normalized().
4) WASD لا يعمل عند بعض اللاعبين: المفاتيح مربوطة بالحرف المكتوب بدل الموضع الفيزيائي.
اربط الكود بأفعال من Input Map، واختر المفتاح الفيزيائي كي تعمل WASD على اللوحة العربية.
الجاذبية تُضاف إلى velocity مضروبة في delta، أما move_and_slide فتستخدم delta داخلياً.
ارتفاع القفزة يساوي مربع السرعة على ضعف الجاذبية، فاضبطه بالحساب.
في canvas اقرأ e.code لا e.key عند ربط مفاتيح الحركة.
مخطط
Keyboard / Gamepad
↓
Input Map: move_left / jump
↓
velocity.x = dir * SPEED
velocity.y += gravity * delta
↓
move_and_slide()←يتوقف عند الأرض
مختبر: ضبط القفزة
احسب السرعة اللازمة لقفزة بارتفاع 120 بكسل بجاذبية 980، ثم جرّبها في اللعبة وقِس الارتفاع بوضع منصة على ارتفاع 115 بكسل: هل يصل إليها اللاعب؟
مهمة عملية
ابنِ شخصية تمشي يميناً ويساراً وتقفز على أرض StaticBody2D، مع أفعال Input Map مربوطة بالأسهم و WASD الفيزيائية. تحقق: القفز يحدث مرة واحدة لكل ضغطة، والسرعة لا تتغير بتغيير لغة لوحة المفاتيح.
اختبر فهمك (1/3)
لماذا نستخدم get_vector أو normalized() في لعبة منظور علوي؟
اختبر فهمك (2/3)
كتبت velocity = direction * speed * delta ثم move_and_slide() فزحف اللاعب ببطء. ما السبب؟
اختبر فهمك (3/3)
لاعب لوحته مضبوطة على العربية، ومفتاح A لا يحرك الشخصية في لعبة canvas تقرأ e.key. ما الحل؟
الهدف: تفرّق بين كشف التصادم والاستجابة له، وتستخدم Area2D والطبقات في Godot، وتكتب كشف AABB بنفسك في canvas.
سؤال التصادم في أي لعبة له شقان منفصلان: الكشف (Detection)، أي هل تلامس الشكلان؟ والاستجابة (Response)، أي ماذا يحدث بعد التلامس؟ قد يتوقف اللاعب عند جدار، أو تختفي عملة وترتفع النقاط، أو يخسر حياة. الخلط بين الشقين وراء كثير من الأخطاء، لأن بعض العقد تكشف فقط ولا تستجيب.
في Godot تكشف Area2D دخول جسم إليها دون أن توقفه، فهي مناسبة للعملات والأبواب ومناطق الخطر. أما StaticBody2D فجدار حقيقي، و move_and_slide توقف اللاعب عنده أو تزلقه على طوله. هذا سكربت عملة جذرها Area2D وتحتها Sprite2D و CollisionShape2D بدائرة صغيرة:
extends Area2D
func _ready():
body_entered.connect(_on_body_entered)
func _on_body_entered(body):
if body.is_in_group("player"):
queue_free()
السطر داخل _ready يوصل إشارة body_entered بدالتنا. الإشارات (Signals) طريقة Godot لإعلان أن شيئاً حدث، وتصل معها العقدة الداخلة كوسيط. ونفحص المجموعة player بدل اسم العقدة لأن الاسم ينكسر عند أول إعادة تسمية؛ أضف اللاعب إلى المجموعة من تبويب Node ثم Groups. و queue_free تحذف العقدة في نهاية الإطار الحالي لا فوراً، فلا ينهار كود ما زال يستخدمها في الإطار نفسه.
طبقات التصادم (Collision Layers) والأقنعة (Masks) تحدد من يرى من. الطبقة تحدد أين يوجد الجسم، والقناع يحدد على أي طبقات يبحث عن أجسام أخرى. إن وضعت اللاعب على الطبقة 1 والعملات على الطبقة 2، فيجب أن يتضمن قناع العملة الطبقة 1 كي تكتشف اللاعب، وبذلك تمنع أيضاً أن تتفاعل العملات مع بعضها أو مع الأعداء دون داعٍ.
في canvas لا يوجد محرك فيزياء، فتكتب الكشف بنفسك. أبسط طريقة لمستطيلين غير مائلين تسمى AABB، وتعتمد على أربع مقارنات:
مثال محسوب: لاعب عند x = 100 و y = 50 بعرض وارتفاع 32، وعملة عند x = 120 و y = 70 بمقاس 16. المقارنات الأربع: 100 < 136، و 132 > 120، و 50 < 86، و 82 > 70، وكلها صحيحة فهناك تقاطع. لو كانت العملة عند x = 140 لصارت المقارنة الثانية 132 > 140 خاطئة، فلا تقاطع. وللدوائر قاعدة أبسط: يتلامس الشكلان إذا كانت المسافة بين المركزين أقل من مجموع نصفي القطرين، والأسرع أن تقارن مربع المسافة بمربع المجموع لتتجنب حساب الجذر التربيعي في كل إطار.
الكشف وحده لا يكفي، فالدرس السابق ترك لاعب canvas يسقط بلا نهاية. هذه استجابة الأرض: إذا تقاطع اللاعب مع مستطيل الأرض وكانت سرعته الرأسية موجبة، أي أنه يهبط، فضع حافته السفلى على سطح الأرض بجعل p.y يساوي ground.y ناقص p.h، وصفّر p.vy، وسجّل أنه على الأرض كي يُسمح له بالقفز. هذا بالضبط ما تفعله move_and_slide في Godot نيابة عنك.
المقارنة بين الطريقتين تساعدك على الاختيار: المحرك يعطيك أشكالاً مائلة ومضلعات وطبقات وإشارات جاهزة، وكتابة AABB بيدك تكفي لعشرات المستطيلات في لعبة canvas صغيرة وتعلمك ما يحدث في الداخل. في الحالتين احذر مشكلة النفق: رصاصة صغيرة سريعة قد تنتقل في خطوة واحدة من أمام جدار رقيق إلى خلفه دون أن تتقاطع معه في أي إطار. الحلول: جدران أسمك، أو سرعة أقل، أو تفعيل الكشف المستمر (Continuous CD) في RigidBody2D داخل Godot.
قائمة فحص حين لا تختفي العملة:
1) هل لكل من اللاعب والعملة CollisionShape2D بشكل فعلي، لا خاصية Shape فارغة؟
2) هل يتضمن قناع العملة طبقة اللاعب؟
3) هل اللاعب في المجموعة player بالحروف نفسها؟
4) هل خاصية Monitoring في Area2D مفعّلة؟
ولرؤية الأشكال أثناء اللعب فعّل Visible Collision Shapes من قائمة Debug. وإن ظهر خطأ يمنع تغيير حالة الفيزياء أثناء معالجة إشارة تصادم، فأجّل التغيير إلى نهاية الإطار بالسطر $CollisionShape2D.set_deferred("disabled", true).
الكشف يجيب «هل تلامسا؟» والاستجابة تجيب «ماذا يحدث؟»، و Area2D تكشف دون أن توقف.
قناع العملة يجب أن يتضمن طبقة اللاعب كي تكتشفه.
AABB أربع مقارنات تكفي لمستطيلات غير مائلة في لعبة canvas صغيرة.
الأجسام الصغيرة السريعة قد تعبر الجدران الرقيقة؛ عالجها بجدران أسمك أو كشف مستمر.
شجرة القرار
كشف: هل تقاطع الشكلان؟
↓
لا←لا شيء
↓
نعم←استجابة
↓
عملة: نقاط + queue_free()
جدار: move_and_slide يوقف
عدو: lose_life()
مختبر: 5 عملات وجدار
انسخ مشهد العملة 5 مرات، وضع StaticBody2D كجدار — فعّل Visible Collision Shapes وتأكد أن اللاعب يتوقف عند الجدار وأن كل العملات تختفي عند اللمس.
مهمة عملية
أضف 3 عملات Area2D على الطبقة 2 وعائقاً StaticBody2D، واضبط الأقنعة بحيث تكتشف العملات اللاعب فقط. تحقق: اللاعب لا يمر من العائق، وكل عملة تختفي مرة واحدة.
اختبر فهمك (1/3)
أي تركيبة مناسبة لعملة يجمعها اللاعب في Godot؟
اختبر فهمك (2/3)
اللاعب على الطبقة 1 والعملات على الطبقة 2، والعملة لا تكتشف اللاعب. ما الإعداد المطلوب؟
اختبر فهمك (3/3)
في دالة overlaps، لاعب عند x = 100 بعرض 32، وعملة عند x = 140. هل يتقاطعان أفقياً؟
5. النقاط والحالة والحفظ — من الفوز والخسارة إلى أفضل نتيجة
الهدف: تبني score و lives في Autoload، وتتجنب فخ إعادة اللعب، وتحفظ أفضل نتيجة بين الجلسات في Godot و canvas.
حالة متخيَّلة توضح المشكلة: طالبة تصنع لعبة جمع عملات وتعرضها على زملائها. يحقق أحدهم 340 نقطة، ثم يغلق اللعبة ويعيد فتحها فيجد «أفضل نتيجة: 0». النقاط كانت تعيش في متغير داخل الذاكرة، والذاكرة تُمسح عند إغلاق البرنامج. هذا الدرس يرتب الحالة في ثلاث طبقات: حالة لحظية داخل العقد، وحالة الجلسة في Autoload، وحالة دائمة في ملف حفظ.
حالة الجلسة أولاً. لو وضعت النقاط في سكربت اللاعب ثم انتقلت إلى مشهد Game Over لحُذف اللاعب وضاعت النقاط معه. الحل Autoload: سكربت يحمّله Godot مرة واحدة عند التشغيل ويبقى متاحاً من أي مشهد باسمه. أنشئ game_state.gd، ثم من Project Settings افتح تبويب Globals (يظهر باسم Autoload في بعض إصدارات Godot 4)، واختر الملف وسمّه GameState:
extends Node
signal score_changed(new_score)
signal lives_changed(new_lives)
var score = 0
var lives = 3
func add_point(amount = 1):
score += amount
score_changed.emit(score)
func lose_life():
lives -= 1
lives_changed.emit(lives)
if lives <= 0:
get_tree().change_scene_to_file("res://game_over.tscn")
func reset():
score = 0
lives = 3
من العملة تكتب GameState.add_point(10) قبل queue_free، ومن العدو GameState.lose_life(). الإشارة score_changed تجعل GameState يعلن التغيير دون أن يعرف من يستمع، فتستطيع الواجهة والصوت الاستماع دون أن تبحث العملة عن أي منهما، وتعيد تصميم الواجهة لاحقاً دون لمس كود العملة.
فخ Autoload الأشهر: لأنه لا يُحذف عند تغيير المشهد، فإن زر «العب مجدداً» في شاشة Game Over يبدأ المستوى والحياة صفر والنقاط القديمة ما زالت محسوبة، فتنتهي اللعبة عند أول لمسة. استدعِ GameState.reset() قبل تغيير المشهد. ولاحظ أن الشرط lives <= 0 أأمن من == 0، فلو صار الضرر لاحقاً حياتين دفعة واحدة وقفزت القيمة من 1 إلى -1 يبقى الشرط صحيحاً.
الفوز يحتاج شرطاً واضحاً أيضاً. بدل رقم ثابت مثل «اجمع 50 نقطة» ضع كل عملة في مجموعة coins، وبعد كل جمع احسب العملات المتبقية بـ get_tree().get_nodes_in_group("coins").size(). تذكّر أن queue_free لا تحذف العقدة إلا في نهاية الإطار، فالعملة التي جُمعت للتو قد تظل محسوبة؛ لذلك أخرجها من المجموعة بـ remove_from_group("coins") قبل الحساب. حين يصل العدد إلى صفر انتقل إلى مشهد الفوز. ميزة هذا الأسلوب أنك تضيف عملات إلى المستوى أو تحذفها دون تعديل أي رقم في الكود.
الحالة الدائمة: لحفظ أفضل نتيجة بين الجلسات يكفي في لعبة صغيرة صنف ConfigFile، وهو ملف نصي بأقسام ومفاتيح. أضف هذه الدوال إلى GameState، واستدعِ load_best في _ready:
const SAVE_PATH = "user://save.cfg"
var best = 0
func save_best():
var cfg = ConfigFile.new()
cfg.set_value("meta", "version", 1)
cfg.set_value("progress", "best", best)
cfg.save(SAVE_PATH)
func load_best():
var cfg = ConfigFile.new()
if cfg.load(SAVE_PATH) == OK:
best = cfg.get_value("progress", "best", 0)
المسار user:// مجلد خاص بالمستخدم يُسمح بالكتابة فيه، بخلاف res:// الذي يصبح للقراءة فقط بعد التصدير. وحسب توثيق Godot يشير user:// في تصدير الويب إلى نظام ملفات افتراضي محفوظ في المتصفح عبر IndexedDB، فإن مسح اللاعب بيانات المتصفح ضاع الحفظ. والقيمة 0 في get_value هي الافتراضية حين لا يوجد المفتاح بعد، أي عند أول تشغيل.
المقابل في لعبة canvas هو localStorage، وهو يخزن نصوصاً فقط، لذلك نحوّل الرقم ذهاباً وإياباً:
let best = Number(localStorage.getItem("best")) || 0;
function onGameOver(score) {
if (score > best) {
best = score;
localStorage.setItem("best", String(best));
}
}
إن لم يوجد المفتاح تعيد getItem القيمة null، و Number(null) تساوي صفراً، و || 0 تحمي من القيم غير الرقمية مثل NaN.
ماذا تحفظ؟ في لعبة صغيرة احفظ ما يريد اللاعب أن يجده عند عودته: أفضل نتيجة، والمستويات المفتوحة، وإعدادات الصوت، ولا تحاول حفظ موقع كل عدو. رقم الإصدار في قسم meta يخبرك لاحقاً كيف تقرأ ملفات قديمة إن غيّرت ما تحفظه. وتذكّر أن ملف الحفظ على جهاز اللاعب يمكن تعديله بسهولة؛ هذا مقبول في لعبة فردية، لكنه لا يصلح أساساً لقائمة متصدرين على الإنترنت.
Autoload يحفظ حالة الجلسة بين المشاهد، لكنه لا يُصفّر نفسه فاستدعِ reset قبل إعادة اللعب.
الإشارات تفصل منطق النقاط عن الواجهة والصوت.
ConfigFile في user:// يحفظ أفضل نتيجة، وفي الويب يُخزَّن عبر IndexedDB في المتصفح.
localStorage في canvas يخزن نصوصاً فقط، فحوّل الأرقام بـ Number و String.
مخطط
لحظية: موقع اللاعب→داخل العقدة
جلسة: score / lives→GameState (Autoload)
دائمة: best→user://save.cfg أو localStorage
Coinadd_point←GameStatescore_changed←HUD
مختبر: أفضل نتيجة تبقى
اجمع نقاطاً ثم أغلق اللعبة وأعد فتحها: هل تظهر أفضل نتيجة؟ ثم احذف ملف save.cfg من مجلد user (Project ثم Open User Data Folder) وتأكد أن اللعبة تبدأ بصفر دون خطأ.
مهمة عملية
نفّذ GameState بالنقاط والحياة و reset، وأضف حفظ أفضل نتيجة في user://save.cfg. تحقق: بعد إغلاق اللعبة وفتحها تظهر أفضل نتيجة، وزر إعادة اللعب يبدأ بثلاث حيوات.
اختبر فهمك (1/3)
لماذا يفيد Autoload مثل GameState في هذه اللعبة؟
اختبر فهمك (2/3)
ضغط اللاعب «العب مجدداً» فانتهت اللعبة عند أول لمسة، وظهرت نقاطه القديمة. ما السبب الأرجح؟
اختبر فهمك (3/3)
صدّرت لعبة Godot للويب. أين تُحفظ ملفات user:// هناك؟
الهدف: تبني HUD ثابتاً يتحدث بالإشارات، وتعرض نصاً عربياً متصل الحروف وصحيح الاتجاه في Godot و canvas.
الواجهة الثابتة فوق اللعب (HUD) تعرض ما يحتاج اللاعب أن يعرفه دون أن يبحث عنه: النقاط والحياة والوقت. أول مشكلة تواجهها: حين تضيف Camera2D تتبع اللاعب يتحرك كل ما في المشهد نسبة إلى الشاشة، فيبتعد نص النقاط مع أول خطوة. الحل وضع الواجهة داخل CanvasLayer، وهي طبقة رسم مستقلة لا تتأثر بالكاميرا وتُرسم فوق العالم.
البنية المقترحة: CanvasLayer، تحتها عقدة Control باسم HUD تحمل السكربت، وتحتها VBoxContainer يرتب عقدتي Label عمودياً تلقائياً باسمي ScoreLabel و LivesLabel. من شريط الأدوات أعلى المحرر اختر Anchor Preset مثل Top Right، كي تبقى الواجهة في زاويتها مهما تغيّر مقاس النافذة أو شاشة الهاتف.
قبل وبعد: نسخة أولى شائعة تحدّث النص داخل _process بمسار كامل مثل $VBoxContainer/ScoreLabel، فتعيد كتابته 60 مرة في الثانية ولو لم يتغير شيء، وتنكسر برسالة Node not found إن نُقلت العقدة. النسخة المحسّنة تستمع لإشارات GameState من الدرس السابق وتحدّث مرة واحدة عند التغيير:
extends Control
@onready var score_label = %ScoreLabel
@onready var lives_label = %LivesLabel
func _ready():
GameState.score_changed.connect(_on_score_changed)
GameState.lives_changed.connect(_on_lives_changed)
_on_score_changed(GameState.score)
_on_lives_changed(GameState.lives)
func _on_score_changed(value):
score_label.text = "النقاط: %d" % value
func _on_lives_changed(value):
lives_label.text = "الحياة: %d" % value
@onready تؤجل البحث عن العقدة حتى تجهز الشجرة، و %ScoreLabel تعني اسماً فريداً (Unique Name) تفعّله بالنقر بالزر الأيمن على العقدة واختيار Access as Unique Name، فيبقى المرجع صحيحاً حتى لو نقلتها داخل حاوية أخرى. واستدعاء الدالتين في _ready يعرض القيم الابتدائية قبل أول تغيير. وللمؤقت أضف عقدة Timer بزمن ثانية واحدة مع Autostart، ووصّل إشارتها timeout بدالة تنقص الوقت المتبقي وتحدّث نصه.
النص العربي في الألعاب يطرح مشكلتين مختلفتين. الأولى التشكيل (Shaping): الحرف العربي يتغير شكله حسب موقعه، فالباء في أول الكلمة غيرها في آخرها، والمحرك الذي لا يشكّل يعرض حروفاً منفصلة بأشكالها المعزولة. الثانية الاتجاه الثنائي (BiDi): الجملة العربية من اليمين لليسار، لكن الأرقام والكلمات اللاتينية داخلها من اليسار لليمين، والمحرك الذي يتجاهل ذلك يعكس الكلمات أو يبعثر ترتيبها.
يذكر توثيق Godot 4 أن دعم الكتابة الثنائية الاتجاه وعكس الواجهة يعمل تلقائياً دون إعداد خاص، والمشكلة الأشيع عنده هي الخط: إن لم يحتوِ الخط المستخدم محارف عربية ظهرت مربعات فارغة. استورد خطاً عربياً بصيغة TTF أو OTF واجعله الخط الافتراضي من إعدادات المشروع أو عبر Theme، واستخدم خاصية layout_direction لعكس ترتيب عناصر الواجهة عند الحاجة.
في Unity ظل مكوّن TextMeshPro الشائع سنوات لا يشكّل العربية تلقائياً، فاعتمد كثير من المطورين على إضافات لإعادة التشكيل. ويوثّق Unity 6 مولّد نص متقدماً (Advanced Text Generator) في UI Toolkit يدعم لغات من اليمين لليسار مثل العربية، ويُفعَّل من إعدادات UI Toolkit؛ اختبر سلوك إصدارك قبل أن تلتزم بأسلوب. أما في canvas فالمتصفح يتولى التشكيل، وعليك ضبط الاتجاه والمحاذاة قبل الرسم:
على الهاتف اجعل حجم نص الواجهة أكبر مما يبدو مناسباً على الحاسوب، وفعّل خاصية autowrap_mode في Label للنصوص الطويلة مثل تعليمات البداية، كي تلتف داخل عرضها بدل أن تخرج من الشاشة. ولا تلصق الواجهة بحافة الشاشة تماماً، فبعض الهواتف فيها نتوء للكاميرا أو حواف منحنية تخفي ما تحتها.
اختبار سريع لأي واجهة عربية: اعرض الجملة «المستوى 3 من 10 — Boss» في نص واحد، وتحقق من ثلاثة أشياء: الحروف متصلة، والرقمان في موضعهما الصحيح، والكلمة الإنجليزية لا تقلب ترتيب ما حولها. ثم صغّر النافذة وتأكد أن النص لا يُقص، لأن الترجمة العربية أطول أحياناً من الإنجليزية. وتأكد من رخصة الخط قبل تضمينه؛ خطوط كثيرة على Google Fonts بترخيص OFL الذي يسمح بتضمينها في الألعاب.
CanvasLayer تبقي الواجهة ثابتة فوق العالم مهما تحركت الكاميرا.
الإشارات مع الأسماء الفريدة تجعل الواجهة تتحدث عند التغيير فقط وتقاوم إعادة الترتيب.
العربية تحتاج تشكيلاً واتجاهاً ثنائياً، و Godot 4 يتولاهما لكن الخط يجب أن يحتوي محارف عربية.
في canvas اضبط ctx.direction و textAlign، واختبر جملة تخلط العربية بالأرقام والإنجليزية.
مخطط
CanvasLayer
↓
HUD (Control)
%ScoreLabel→score_changed
%LivesLabel→lives_changed
Timer←الوقت المتبقي
خط عربي TTF/OTF←Theme←حروف متصلة
مختبر: HUD عربي حي
اجمع عملة واخسر حياة: هل يتحدث الرقمان فوراً؟ ثم احذف الخط العربي مؤقتاً وصدّر للويب لترى المربعات الفارغة، وأعد الخط وقارن.
مهمة عملية
ابنِ HUD يعرض النقاط والحياة ومؤقتاً تنازلياً من 60 ثانية بنصوص عربية، يتحدث بالإشارات. تحقق: الحروف متصلة، والأرقام في موضعها، والواجهة تبقى في الزاوية عند تحريك الكاميرا وتغيير مقاس النافذة.
اختبر فهمك (1/3)
أي عقدة تُبقي الواجهة ثابتة فوق رسوم اللعبة رغم حركة الكاميرا؟
اختبر فهمك (2/3)
ظهرت مربعات فارغة مكان النص العربي في Label بعد تصدير لعبة Godot 4. ما السبب الأرجح؟
اختبر فهمك (3/3)
لماذا تُفضَّل الإشارات على تحديث نص النقاط داخل _process؟
الهدف: تضيف مؤثرات الجمع والخسارة وموسيقى خلفية، وتحل أعطال الصوت الشائعة في Godot والمتصفح.
الصوت يخبر اللاعب بما حدث قبل أن تلتقطه عينه: رنين قصير يعني أن العملة جُمعت، وصوت منخفض يعني ضرراً. في Godot عقدتان أساسيتان: AudioStreamPlayer لصوت بلا موقع يُسمع بالقوة نفسها دائماً، ويناسب الموسيقى وأصوات الواجهة، و AudioStreamPlayer2D لصوت له موقع في العالم يخفت كلما ابتعد مصدره عن الكاميرا، ويناسب انفجاراً بعيداً أو عدواً يقترب.
اختيار الصيغة قاعدة بسيطة: WAV للمؤثرات القصيرة لأن تشغيلها لا يحتاج فك ضغط يُذكر، و OGG Vorbis للموسيقى الطويلة لأنه مضغوط وحجمه صغير. للموسيقى المتكررة اختر الملف في FileSystem، ثم من تبويب Import فعّل Loop واضغط Reimport. المؤثر القصير لا يحتاج تكراراً. وقاعدة توزيع بسيطة: كل فعل متكرر كالقفز والجمع يحتاج صوتاً قصيراً جداً، وكل حدث نادر كالفوز يستحق صوتاً أطول وأوضح، واجعل مؤثرات الخطر مختلفة في طبقتها عن مؤثرات المكافأة كي يميز اللاعب بينها دون أن ينظر.
ما يلي سجل أعطال من مشروع لعبة جمع عملات، مرتب كما تظهر الأعطال عادة.
العطل الأول: صوت الجمع لا يُسمع إطلاقاً. مشغّل الصوت داخل مشهد العملة، والكود يستدعي play ثم queue_free مباشرة، فتُحذف العقدة ومعها الصوت قبل أن يبدأ. الحل إما نقل المشغّل إلى اللاعب أو المشهد الرئيسي، وإما إخفاء العملة وانتظار انتهاء الصوت:
await توقف هذه الدالة وحدها حتى تصدر إشارة finished دون أن تجمّد اللعبة، وتعطيل التصادم يمنع جمع العملة المخفية مرة ثانية أثناء الانتظار.
العطل الثاني: الصوت نفسه يتكرر مئات المرات فيصير مزعجاً. غيّر طبقته قليلاً قبل كل تشغيل بالسطر $CoinSound.pitch_scale = randf_range(0.9, 1.1)، فيبدو كل جمع مختلفاً قليلاً دون ملفات إضافية.
العطل الثالث: الموسيقى طاغية ولا يستطيع اللاعب خفضها وحدها. من تبويب Audio أسفل المحرر أنشئ مسارين (Buses) باسم Music و SFX يصبان في Master، واضبط خاصية Bus في كل مشغّل. بعدها تتحكم في كل مسار من الكود، مثلاً لكتم كل شيء من زر في الإعدادات:
var master = AudioServer.get_bus_index("Master")
AudioServer.set_bus_mute(master, true)
وتذكّر أن خاصية volume_db لوغاريتمية: صفر هو الحجم الأصلي والقيم السالبة تخفض، وخفض 6 ديسيبل ينصّف سعة الموجة تقريباً، وهذا لا يعني أن الأذن تسمعه نصف الصوت بالضبط.
العطل الرابع: في نسخة الويب لا تبدأ الموسيقى عند فتح الصفحة. المتصفحات تمنع التشغيل التلقائي للصوت قبل أن يتفاعل المستخدم مع الصفحة، وشاشة بداية بزر «العب» تحل ذلك طبيعياً لأن النقرة تمنح الإذن. ويوضح توثيق Godot أن الإصدار 4.3 وما بعده يشغّل الصوت على الويب عبر Web Audio API بوضع يسمى Sample، وهو منخفض التأخير لكنه لا يدعم مؤثرات الصوت (AudioEffects) مثل الصدى، ويمكن تحويل مشغّل معين إلى وضع Stream مقابل تأخير أعلى.
في canvas مع JavaScript يكفي للبداية عنصر Audio:
const coin = new Audio("coin.wav");
function playCoin() {
coin.currentTime = 0;
coin.play().catch(() => {});
}
الدالة play تعيد Promise يُرفض إن منع المتصفح التشغيل، و catch تمنع ظهور خطأ غير معالج في Console. إعادة currentTime إلى صفر تسمح بتشغيل الصوت من بدايته عند جمع عملتين متتاليتين، وللتحكم الأدق في التداخل والحجم انتقل لاحقاً إلى Web Audio API.
وأخيراً التراخيص والراحة: كثير من أصوات freesound.org برخصة CC-BY التي تشترط ذكر صانع الصوت، وبعضها يمنع الاستخدام التجاري، فسجّل رخصة كل ملف. واختبر المستوى على سماعة الهاتف وسماعات الرأس معاً، فالمؤثر الحاد قد يؤذي الأذن في إحداهما.
WAV للمؤثرات القصيرة و OGG Vorbis مع Loop للموسيقى الطويلة.
لا تحذف العقدة قبل انتهاء صوتها: أخفِها وانتظر finished أو انقل المشغّل.
مسارات Music و SFX تمنح اللاعب تحكماً منفصلاً في الحجم.
المتصفح يمنع الصوت قبل تفاعل المستخدم، وشاشة البداية بزر تحل ذلك.
مخطط
[Coin collected]
|
v
hide() + disable collision
|
v
$CoinSound.play() → await finished → queue_free()
Music ─┐
SFX ───┴─→ Master → السماعة
مختبر: صوتان ومساران
أضف مؤثر جمع ومؤثر خسارة على مسار SFX، وموسيقى على مسار Music — اجمع خمس عملات بسرعة: هل يُسمع كل جمع؟ هل تطغى الموسيقى؟
مهمة عملية
أضف صوت جمع عملة وصوت خسارة حياة وموسيقى متكررة، على مسارين منفصلين. تحقق: صوت الجمع يُسمع كاملاً قبل حذف العملة، وكتم Music لا يكتم المؤثرات.
اختبر فهمك (1/3)
متى تشغّل مؤثر جمع العملة في Godot؟
اختبر فهمك (2/3)
كتبت $CoinSound.play() ثم queue_free() داخل العملة فلم يُسمع شيء. لماذا؟
اختبر فهمك (3/3)
موسيقى لعبة الويب لا تبدأ عند فتح الصفحة، وتعمل بعد أول نقرة. لماذا؟
الهدف: تضيف تغذية راجعة بصرية بـ Tween والجزيئات والاهتزاز، وتدير جلسة اختبار لعب تخرج منها بتعديلات واضحة.
تخيّل نسختين من اللعبة نفسها: في الأولى تختفي العملة بصمت، وفي الثانية تكبر لجزء من الثانية ثم تتلاشى مع رنين قصير وبضع شرارات. الآليات متطابقة، لكن الثانية تبدو حية. هذه التغذية الراجعة الصغيرة تسمى عصارة اللعبة (Game Juice)، وهي آخر ما تضيفه بعد أن تعمل اللعبة، وأول ما يلاحظه اللاعب.
أداة Tween تحرك أي خاصية من قيمة إلى أخرى خلال زمن محدد دون أن تحسب كل إطار بنفسك:
خطوات tween الواحد تُنفَّذ بالتتابع افتراضياً، فهنا تكبير ثم عودة. لتنفيذ حركتين معاً، كتكبير وتلاشٍ في الوقت نفسه، استدعِ tween.set_parallel(true) قبلهما. و set_trans(Tween.TRANS_BACK) تعطي نهاية الحركة ارتداداً طفيفاً. وتنبيه مهم: الـ tween المرتبط بعقدة يتوقف إن حُذفت، فإن أردت حركة وداعية للعملة فاحذفها بعد انتهائها بالسطر tween.finished.connect(queue_free).
الجزيئات (Particles): عقدة CPUParticles2D بخيار One Shot وعدد قليل من الجزيئات، تشغّلها بـ emitting = true لحظة الجمع. في نسخ الويب والأجهزة الضعيفة ابدأ بنسخة CPU لأن سلوكها أسهل توقعاً عبر المتصفحات. واهتزاز الكاميرا يتم بتحريك خاصية offset في Camera2D عشوائياً ثم إعادتها إلى الصفر، في سكربت مرفق بالكاميرا:
عند الضرر اكتب shake = 8 فيهتز المشهد ثم يهدأ. نقص shake مضروباً في delta يجعل الهدوء بالسرعة نفسها على كل الأجهزة. والتوقف الخاطف (Hit Stop) بخفض Engine.time_scale لجزء من الثانية يعطي الضربة ثقلاً. لكن الاهتزاز المبالغ فيه يسبب دواراً لبعض اللاعبين، فأضف خياراً لإيقافه في الإعدادات؛ هذا جزء من إتاحة اللعبة للجميع.
قاعدتان عند إضافة أي لمسة: يجب أن تبدأ الاستجابة في الإطار نفسه الذي ضغط فيه اللاعب، فالتأخير الملحوظ بين الضغطة وردة الفعل يجعل التحكم ثقيلاً مهما كانت المؤثرات جميلة. ولا تدع المؤثر يغطي معلومة مهمة؛ جزيئات كثيفة فوق عدو قادم تجعل اللاعب يخسر بسبب الزينة.
كل هذه اللمسات تخمين حتى يلعبها شخص آخر. اختبار اللعب (Playtesting) له بروتوكول بسيط يجعل نتائجه مفيدة:
1) قبل الجلسة: اكتب ثلاثة أسئلة تريد إجابتها، مثل «هل يفهم اللاعب الهدف دون شرح؟» و«أين يخسر أول حياة؟».
2) أثناء الجلسة: سلّم اللعبة واجلس صامتاً، دون شرح أو دفاع. سجّل الدقيقة التي تردد فيها اللاعب أو ابتسم أو سأل.
3) بعد الجلسة: اسأل أسئلة مفتوحة مثل «ما الذي شعرت أنه ظالم؟» بدل «هل أعجبتك؟» التي تجلب مجاملة.
4) غيّر شيئاً أو اثنين فقط، ثم اختبر مع شخص جديد لم يرَ النسخة السابقة.
مثال متخيَّل يوضح الدورة: نادي برمجة مدرسي يختبر لعبة جمع عملات مع خمسة زملاء. الملاحظة المتكررة أن ثلاثة منهم لم ينتبهوا إلى أن العملة جُمعت لأنها تختفي بلا أثر، واثنين اصطدما بالعدو الأول قبل أن يفهما أنه خطر. التعديل في الجولة الثانية: tween وصوت للجمع، ولون أحمر واهتزاز خفيف للعدو. مع خمسة لاعبين جدد اختفى السؤال الأول، وصار النقاش عن صعوبة الدقيقة الأخيرة، وهي علامة أن الأساسيات صارت واضحة.
لاحظ أن الملاحظة الواحدة من لاعب واحد قد تكون ذوقاً شخصياً، أما الملاحظة التي يكررها ثلاثة من خمسة فمشكلة حقيقية. وللتعمق في تصميم الإحساس واختبار اللعب، يضم أرشيف GDC Vault محاضرات كثيرة لمصممين محترفين، وبعضها متاح مجاناً.
Tween يحرك الخصائص بالتتابع افتراضياً، و set_parallel يشغّل الخطوات معاً.
اربط نقص الاهتزاز بـ delta، وأضف خياراً لإيقافه احتراماً لمن يصيبه الدوار.
في اختبار اللعب: أسئلة مكتوبة مسبقاً، وصمت أثناء الجلسة، وأسئلة مفتوحة بعدها.
الملاحظة التي تتكرر عند عدة لاعبين أولى بالإصلاح من رأي فردي.
مخطط
[حدث: جمع]
|
+---+---+------+
v v v
Tween · Particles · Sound
|
v
اختبار لعب → ملاحظة متكررة → تعديل واحد → لاعب جديد
مختبر: لمسة واحدة كاملة
اختر اهتزاز الكاميرا أو جزيئات العملة، ونفّذه كاملاً مع خيار إيقاف — ثم اطلب من شخص واحد أن يلعب دقيقتين وسجّل ملاحظاته دون أن تتكلم.
مهمة عملية
أضف قائمة بداية بزر «العب» وتأثير Tween عند جمع العملة، ثم أدر جلسة اختبار مع لاعبين اثنين على الأقل بالبروتوكول. تحقق: ورقة الجلسة فيها ثلاثة أسئلة وملاحظات بالدقيقة وتعديل واحد نُفّذ بعدها.
اختبر فهمك (1/3)
ماذا يُقصد غالباً بـ«عصارة اللعبة» (Game Juice)؟
اختبر فهمك (2/3)
تريد تكبير العملة وتلاشيها في الوقت نفسه بـ Tween واحد. ماذا تضيف؟
اختبر فهمك (3/3)
أثناء جلسة اختبار تردد اللاعب عند أول قفزة وسأل عن الزر. ما التصرف الأنسب؟
الهدف: تصدّر لعبتك للويب وتنشرها على itch.io، وتعرف متطلبات Windows و Android قبل أن تبدأ بها.
التصدير يحوّل مشروعك إلى ملفات يشغّلها جهاز لا يملك Godot. يحتاج المحرك أولاً قوالب التصدير (Export Templates)، وهي نسخ مسبقة البناء من المحرك تُدمج مع ملفات لعبتك. نزّلها من قائمة Editor ثم Manage Export Templates، ويجب أن تطابق إصدار المحرر بالضبط؛ إن حدّثت Godot فحدّث القوالب، وإلا ظهرت في نافذة التصدير رسالة حمراء بأن القالب غير موجود.
تصدير الويب خطوة بخطوة:
1) من Project ثم Export أضف إعداداً من نوع Web.
2) سمِّ الملف index.html؛ يقترح توثيق Godot هذا الاسم، ومنصة itch.io تشترط وجود ملف بهذا الاسم داخل الأرشيف.
3) تأكد أن طريقة الرسم Compatibility، فالتوثيق ينص على أن تصدير الويب في Godot 4 يستهدف WebGL 2.0 بهذه الطريقة فقط.
4) اترك Thread Support مطفأً. منذ Godot 4.3 صار التصدير بخيط واحد هو الافتراضي لأنه لا يحتاج ترويسات خادم خاصة، ويتوافق مع itch.io مباشرة.
5) صدّر إلى مجلد فارغ مثل build/web.
الناتج عدة ملفات: index.html، وملف JavaScript، وملف WebAssembly (wasm) هو المحرك نفسه، وملف pck فيه مشاهدك وأصولك. يحذر التوثيق من إعادة تسمية هذه الملفات بعد التصدير لأنها تتوقع الأسماء التي صُدّرت بها. اضغط محتويات المجلد في ملف ZIP واحد بحيث يكون index.html في جذر الأرشيف، لا داخل مجلد فرعي.
النشر على itch.io: أنشئ حساباً ثم مشروعاً جديداً، واختر HTML في نوع المشروع، وارفع ملف ZIP وفعّل خيار تشغيله في المتصفح. اضبط مقاس الإطار المضمَّن ليطابق دقة لعبتك، وأضف صورة غلاف بالمقاس الموصى به 630×500 ووصفاً يذكر الهدف وأزرار التحكم وقائمة الأصول وتراخيصها. ابدأ بحالة Draft واختبر الرابط من حاسوب وهاتف وفي متصفحين، ثم حوّلها إلى Public.
لعبة canvas أبسط في النشر: ملف index.html مع ملفات JavaScript والصور والأصوات في ZIP واحد، ثم الخطوات نفسها. لكن انتبه لعطل يتكرر هنا: يذكر توثيق itch.io أن أسماء الملفات حساسة لحالة الأحرف. نظام Windows يتسامح، فيعمل كود يطلب Player.png على جهازك والملف اسمه player.png، ثم تختفي الصورة على الخادم. وحّد الأسماء بحروف صغيرة، واستخدم مسارات نسبية مثل art/player.png.
الشاشة السوداء بعد الرفع لها أسباب متكررة: index.html داخل مجلد فرعي في الأرشيف، أو ملف ناقص، أو مشروع مكتوب بـ C#، والتوثيق ينص على أن مشاريع C# في Godot 4 لا تُصدَّر للويب حالياً. افتح Console المتصفح بالمفتاح F12 لترى الخطأ الفعلي بدل التخمين.
وماذا عن الهاتف؟ أمامك طريقان. الأول تصدير الويب نفسه، فيلعبه الناس من متصفح الهاتف دون متجر، مع تنبيه التوثيق إلى أن التطبيق الأصلي أسرع بفارق كبير، ومع ضرورة إضافة أزرار لمس لأن الهاتف بلا لوحة مفاتيح. الثاني تصدير Android أصلي: يطلب التوثيق تثبيت OpenJDK 17 و Android SDK، ويكفي مفتاح التوقيع التجريبي (debug keystore) للتجربة على جهازك. للنشر على Google Play يذكر التوثيق أن التطبيقات الجديدة يجب أن تكون بصيغة AAB موقعة بمفتاح غير تجريبي، فاحفظ ذلك المفتاح وكلمة مروره في مكان آمن لأن تحديثات التطبيق تحتاجه. أما iOS فيمر عبر مشروع Xcode على جهاز Mac.
تصدير Windows ينتج ملف exe ومعه pck، ويمكن دمجهما بخيار Embed PCK. قد يعرض Windows تحذير SmartScreen لأن الملف غير موقّع رقمياً، فاذكر ذلك في صفحة اللعبة كي لا يقلق اللاعبون. وفي كل منصة اكتب في الوصف أن اللعبة صُنعت بمحرك Godot، ولا تنشر أبداً رسوماً أو أصواتاً أو خطوطاً لا تملك حق استخدامها.
قوالب التصدير يجب أن تطابق إصدار المحرر بالضبط.
تصدير الويب في Godot 4 يعمل بطريقة الرسم Compatibility، والخيط الواحد هو الافتراضي منذ 4.3.
index.html في جذر ملف ZIP، وأسماء الملفات على itch.io حساسة لحالة الأحرف.
Google Play يطلب AAB موقعاً بمفتاح غير تجريبي، فاحفظ المفتاح بأمان.
مخطط
[Godot Project]
|
Export (Web, Compatibility, بلا Threads)
|
v
build/web → ZIP (index.html في الجذر)
|
v
[itch.io: Draft → اختبار → Public]
مختبر: رفع Draft
ارفع اللعبة كـ Draft، وافتح الرابط من هاتف وحاسوب وفي متصفحين — إن ظهرت شاشة سوداء افتح Console بـ F12 واكتب أول خطأ يظهر.
مهمة عملية
صدّر اللعبة للويب وارفعها على itch.io كـ Draft أو Public مع غلاف 630×500 ووصف وقائمة أصول. تحقق: الرابط يعمل من جهاز غير جهازك، والصوت يبدأ بعد زر «العب».
اختبر فهمك (1/3)
لنشر لعبة Godot تُلعب داخل المتصفح، إلى أي منصة تصدّر؟
اختبر فهمك (2/3)
رفعت إلى itch.io ملف ZIP ضغطت فيه المجلد build/web نفسه، فظهرت شاشة سوداء. ما الخلل الأرجح؟
اختبر فهمك (3/3)
صورة تظهر على جهازك وتختفي بعد رفع لعبة canvas إلى itch.io، والكود يطلب Player.png والملف player.png. السبب؟
الهدف: تصمم حلقة لعب ومنحنى صعوبة بأرقام قابلة للضبط، وتسلّم لعبة كاملة بنقاط وحفظ وواجهة وصوت ورابط itch.io.
المشروع النهائي لعبة جمع أو هروب قصيرة، تُلعب الجولة فيها بين 60 و 90 ثانية. قبل فتح المحرك اكتب صفحة تصميم واحدة، وأهم سطر فيها حلقة اللعب الأساسية (Core Loop): الفعل الذي يكرره اللاعب كل بضع ثوانٍ، والمكافأة التي تدفعه لتكراره. مثال: «يتحرك اللاعب ليجمع العملات، وكل عملة تضيف نقاطاً وثانيتين للمؤقت، والأعداء يزدادون مع الوقت». إن لم تستطع كتابة الحلقة في جملة واحدة، فالفكرة ما زالت ضبابية.
منحنى الصعوبة (Difficulty Curve) يصف كيف يتغير التحدي مع الوقت. البداية السهلة تعلّم التحكم، ثم يرتفع التحدي تدريجياً، ويُفضَّل أن تتخلله لحظات تنفس قصيرة بعد الذروات. حوّل المنحنى إلى أرقام قابلة للضبط بدل الانطباع؛ مثلاً مؤقت ظهور الأعداء يبدأ من ثانيتين ويقصر مع كل عدو حتى حد أدنى:
@export var start_interval = 2.0
@export var step = 0.05
@export var min_interval = 0.8
var spawned = 0
func _on_spawn_timer_timeout():
spawn_enemy()
spawned += 1
$SpawnTimer.wait_time = max(min_interval, start_interval - spawned * step)
حساب سريع: بعد 10 أعداء يصبح الفاصل 1.5 ثانية، وبعد 24 عدواً يصل إلى 0.8 ويثبت هناك. إن وجد المختبرون البداية مملة فارفع step إلى 0.08، وإن خسر أغلبهم قبل الدقيقة فارفع min_interval. الكلمة @export تُظهر المتغيرات في Inspector، فتعدّل الصعوبة أثناء جلسة الاختبار دون فتح الكود. والدالة spawn_enemy تكتبها بنفسك لإنشاء نسخة من مشهد العدو في موقع عشوائي.
وفرّق بين الصعب والظالم: الصعب أن يعرف اللاعب سبب خسارته ويرغب في المحاولة مجدداً، والظالم أن يظهر عدو فوقه مباشرة دون إنذار. اجعل الأعداء يظهرون عند حافة الشاشة بعيداً عن اللاعب، أو أضف وميضاً قصيراً في مكان الظهور قبل نصف ثانية من ظهور العدو.
الحد الأدنى للتسليم:
• لاعب يتحرك بالأسهم و WASD الفيزيائية
• خمسة أهداف قابلة للجمع على الأقل، وعائق أو عدو واحد
• GameState للنقاط والحياة أو الوقت، وأفضل نتيجة محفوظة في user://
• واجهة HUD تتحدث بالإشارات، بنص عربي متصل الحروف
• صوتان على الأقل، وقائمة بداية، وشاشة نهاية بزر إعادة
• لمسة تحسين واحدة، ورابط itch.io يعمل
تحكّم في الحجم: أكثر الألعاب الأولى لا تكتمل لأن صاحبها أراد عشرة مستويات ومتجراً ونظام حوار. ترتيب عمل مقترح: الحركة، ثم الجمع والنقاط، ثم الخسارة، ثم الواجهة، ثم القوائم، ثم الصوت، ثم الحفظ، وأخيراً اللمسات. بعد كل خطوة يجب أن تبقى اللعبة قابلة للتشغيل. ومع Git يصبح كل نجاح نقطة رجوع: عند إنشاء المشروع يستطيع مدير مشاريع Godot 4 توليد ملف .gitignore يستثني مجلد .godot الذي يعيد المحرك بناءه، فاصنع commit بعد كل خطوة ناجحة.
جرّب الحلقة الكاملة خمس مرات متتالية: البداية، ثم المستوى، ثم الفوز أو الخسارة، ثم إعادة اللعب. هنا تنكشف أخطاء مثل نقاط لا تُصفَّر، وموسيقى تتضاعف لأن مشغلاً جديداً يُنشأ مع كل إعادة، وأعداء لا يُحذفون فيتراكمون. وراقب لوحة Output وتبويب Errors في Debugger؛ النقر المزدوج على الخطأ يفتح السطر المسؤول، واللعبة التي تطبع خطأً في كل إطار قد تنهار على جهاز آخر أو في المتصفح.
اختبر مع ثلاثة أشخاص لم يروا اللعبة باستخدام بروتوكول الدرس الثامن، واضبط أرقام الصعوبة بين الجلسات. ثم اكتب ملف README.txt يضم فكرة اللعبة في سطر، وأزرار التحكم، وقائمة الأصول مع مصدر كل منها ورخصته، ورابط itch.io.
معيار النجاح الحقيقي: لاعب جديد يفهم الهدف ويكمل جولة كاملة دون شرح، ولا أخطاء حمراء في Output، و README يوثّق الأصول، والرابط يعمل من جهاز غير جهازك. بعد ذلك فقط فكّر في مستوى ثانٍ أو نوع عدو جديد.
اكتب حلقة اللعب الأساسية في جملة واحدة قبل أي كود.
حوّل منحنى الصعوبة إلى متغيرات مصدّرة تضبطها بين جلسات الاختبار.
ابنِ بالترتيب وأبقِ اللعبة قابلة للتشغيل بعد كل خطوة، مع commit لكل نجاح.
اختبر حلقة البداية والنهاية والإعادة خمس مرات قبل النشر.
مخطط
حلقة اللعب: تحرّك←اجمع←نقاط + وقت←أعداء أكثر
Menu←Main←Game Over
↓
Play
score++
Retry←reset()
HUD
SFX
↓
itch.io link
مختبر: عرض نهائي
سجّل فيديو 30 ثانية من اللعب، واطلب من شخص لم يرَ اللعبة أن يلعب جولة كاملة دون شرح — هل فهم الهدف في أول عشر ثوانٍ؟
مهمة عملية
سلّم لعبة مصغّرة كاملة مع صفحة تصميم فيها حلقة اللعب وأرقام الصعوبة، ورابط itch.io، و README. تحقق: ثلاثة لاعبين جدد أكملوا جولة دون شرح، ولا أخطاء حمراء في Output بعد خمس إعادات متتالية.
اختبر فهمك (1/3)
أي معيار أفضل للحكم على نجاح المشروع النهائي؟
اختبر فهمك (2/3)
بعد خمس إعادات متتالية تتضاعف الموسيقى ولا تُصفَّر النقاط. ماذا يعني ذلك؟
اختبر فهمك (3/3)
فاصل ظهور الأعداء يبدأ من 2.0 ثانية وينقص 0.05 مع كل عدو بحد أدنى 0.8. متى يبلغ الحد الأدنى؟