البحث في الموقع
المحتوى عن 'دليل جودو'.
-
لنفترض أن لدينا شخصيةً ثلاثية الأبعاد من نوع صلب Rigged تم بناؤها ذاتيًا أو حملناها من مصدر خارجي ونريد إعداد وضبط حركاتها في جودو. سنستكشف في هذا المقال طريقة فعل ذلك، وسنستخدم نفس شخصية المغامر وبقية الأصول التي ذكرناها في المقال السابق من سلسلة دليل جودو. إعداد الشخصية اخترنا العقدة CharacterBody3D لتمثّل شخصية المغامر، لهذا ينبغي أن يكون المشهد كالتالي: ملاحظة: أغلقنا فرع العقدة Rig كي لا تطول القائمة. أول ما قد نلاحظه أن يدي المغامر ممتلئتين، فقد زُوّد الفنان المغامر بكل الأسلحة والدروع ووضعها في مكانها واتجاهها الصحيح. بإمكاننا طبعًا التنقل في قائمة العقد وإخفاء ما نريده. استخدام العقدة AnimationTree من الصعب التعامل مع كل الحركات المتوفرة للشخصية من خلال الشيفرة. لذا من المهم التفكير مثلًا بعدد العبارات الشرطية if التي نحتاجها كي نحدد الحركة المناسبة التي يجب تنفيذها في التوقيت المطلوب، وذلك اعتمادًا على ما يفعله اللاعب. لن يكون الأمر صعبًا بالطبع عند وجود عدة حركات فقط، لكن سرعان ما ستخرج الأمور عن السيطرة، ولن تكون هناك جدوى فعلية منها. علينا التفكير أيضًا في الحالة التي يقف فيها اللاعب دون حراك؛ إذ لا بد عندها من تنفيذ حركة الوقوف دون فعل شيء Idle. وعندما يضغط اللاعب مفتاح الحركة إلى الأمام، ستنفذ الشخصية حركة المشي Walking. ستبدو هذه التغيرات السريعة في الحركة مزعجة، ويُفضّل أن تكون هذه الحركات المتعاقبة متمازجةً ببعضها لتعطي انتقالًا أكثر سلاسة. ولحل مشاكل الحركة المعقدة تلك، يجب استخدام عقدة AnimationTree، وهي عقدة مصممة للتحكم بالعقدة AnimationPlayer وتقدم وظائفًا تتحكم بالانتقال بين الحركات المختلفة وتمزجها معًا. سنضيف العقدة AnimationTree إلى المشهد ، ثم نضبط الخاصية Tree Root في الفاحص Inspector على القيمة AnimationNodeStateMachine، ثم نلحق العقدة AnimationPlayer بهذه العقدة من خلال الخاصية Anim Player، ونفعّل بعدها الخاصية Active ملاحظة: قد نلاحظ أننا لن تتمكن من تشغيل الحركات التي تضمها العقدة AnimationPlayer عندما تكون الخاصية Active للعقدة AnimationTree فعّالة، لذا إن أردنا تعديل أي شيء أو اختبار الحركات، فعلينا إلغاء تفعيل هذه الخاصية مؤقتًا. حلقة الحركات التوقف Idle والمشي Walk والجري Run هنالك الكثير من الحركات التي سنجدها في هذه النماذج، لكننا سنركز في مثالنا على حلقة حركات التوقف Idle والمشي Walk والجري Run، مع القفز والهجوم؛ فإن أردنا إضافة حركات أخرى، سنكرر ببساطة ما سنفعله مع الحركة التي نريد. سنبحث عن الحركات Idle و Running_A و Walking_Backwards و Running_Strafe_Left / Running_Strafe_Right ضمن العقدة AnimationPlayer، وسنتأكد من أن هذه الحركات ستعمل على شكل حلقة. يمكننا اختبار ذلك من خلال تشغيل الحركة عبرالزر ▶، وفي حال كانت هناك أي حركة غير معدّة لتكرار نفسها، سنعيد استيراد الشخصية بعد ضبط الحركات على وضع التكرار. سنختار العقدة AnimationTree وسنرى أن هناك لوحة جديدة قد فُتحت أسفل العارض. وكمثال عن طريقة العمل، سننقر بالزر الأيمن للفأرة في أي نقطة فارغة من اللوحة، ونضغط على خيار أضف حركة Add Animation ثم نختار Idle. نكرر العملية ونضيف أيضًا الحركة 1H_Melee_Attack_Chop. سنضغط الآن على أيقونة ربط العقد Connect Nodes، ثم نرسم اتصالًا بين الزر start و الحركة Idle، وسرى كيف تُنفّذ الحركة Idle مباشرةً. نريد الآن الانتقال من التوقف إلى الهجوم ثم العودة إلى حركة التوقف بعد انتهاء حركة الهجوم. ولهذا، سنرسم اتصالين إضافيين من وإلى حركة الهجوم. لن يعمل ذلك كما هو مطلوب، لكنه سينتقل بين الحركتين بسرعة، لأن كلا الاتصالين ضُبطا للانتقال الفوري. لتغير شروط الانتقال، سننتقل إلى وضع الاختيار، وذلك بالنقر على الأيقونة المخصصة، ثم النقر على أحد الاتصالين، وستظهر في نافذة الفاحص خاصيات الاتصال. نحتاج إلى ضبط الخاصية Advanced>Mode على Enabled في الاتصال من الحركة Idle، ويعني ذلك أن هذا الانتقال سيُنفذ فقط عندما نقرر ذلك وليس آليًا. سنلاحظ بعد ذلك أن لون الأيقونة على خط الاتصال سيتغير. سنضبط الخاصية Switch للاتصال الثاني إلى الحركة Idle على AtEnd، وتبقى قيمة الخاصية Advanced>Mode على Auto. عند النقر الآن على الزر ▶ في عقدة الهجوم، ستعمل حركة الهجوم، ثم تعود إلى حركة التوقف عندما تنتهي. يوضح هذا المثال البسيط طريقة إعداد حركات مختلفة وضبط الانتقالات بين هذه الحركات. سنحتاج لاحقًا بطبيعة الحال إلى أكثر من ذلك، لهذا سنحذف العقد الموجودة في لوحة شجرة التحريك باستخدام أيقونة الحذف، ودعنا نحضّر فضاء المزج blendspace. فضاء المزج blendspace ننقر بالزر اليميني للفأرة على أي نقطة فارغة من لوحة شجرة الحركات ثم نختار BlendSpace2D، وننقر بعد ذلك على الأيقونة التي تظهر، مع تغيير اسمها إلى IWR. نضيف بعد ذلك اتصالًا من Start إلى IWR كي يبدأ فضاء المزج بالعمل تلقائيًا. ننقر بعد ذلك على أيقونة القلم في الفضاء IWR لتظهر لنا النافذة التالية: يمثل هذا الفضاء ثنائي البعد شعاع الحركة الأفقية للشخصية، فعندما تقف الشخصية ساكنة، تكون قيمة الشعاع (0,0). ننقر الآن على زر إنشاء نقاط Create Points، ثم ننقر على منتصف الشبكة لإضافة الحركة idle. نضيف الحركة Running_A في المنتصف والأعلى، والحركة Walking_Backwards في المنتصف والأسفل الحركة، ثم نضيف في النهايتين الأفقيتين الحركتين Running_Strafe_Left و Running_Strafe_Right. يمكننا الآن النقر على أيقونة تحديد موضع الخلط في الفراغ، ثم النقر ضمن الشبكة والسحب لرؤية الانتقال السلس بين الحركات الأربعة والتوقف. بعد الانتهاء من تجربة عملية المزج، سننقر على الجذر Root لتعود إلى لوحة شجرة الحركات. ضبط آلة الحالة State machine يمكن عدّ حلقة الحركات IWR على أنها قلب شجرة الحركات، حيث ستقضي الشخصية معظم الوقت في تنفيذ تلك الحركات، و تتفرع بقية الحركات عنها كما فعلنا قبل قليل مع حركة الهجوم. تعرض لقطة الشاشة التالية مجموعة من الحركات التي ترتبط بالحلقة IWR، ومن المهم هنا تذكر أن خاصيات الانتقال تُضبط بنفس الأسلوب الذي اتبعناه في المثال السابق. يمكننا أيضًا النقر على الحركة لتغيير اسمها، وذلك لأن بعض الأسماء طويلة؛ أما الحركة التي تختلف عن غيرها فهي حركة القفز، إذ تقسم هذه الحركة إلى ثلاثة أجزاء هي البداية Start والعودة land التي تُنفّذ عندما تبدأ الشخصية بالقفز وعندما تنهيه، بينما يمثل الجزء الثالث التوقف Idle حركة وصل تُنفّذ طالما أن الشخصية لا تزال في الجو، خاصةً إن انتقلت أثناء القفز مسافة طويلة مثلًا. سنضيف الآن حركات القفز الثلاث كالتالي: وبما أننا نريد الانتقال مباشرةً من الحلقة IWR إلى الحركة Jump_Idle عند السقوط عن حافة، فعند النقر على زر القفز ستُنفّذ الحركة Jump_Start. وإضافة إلى ذلك، أبقينا الانتقال من Jump_Start إلى Jump_Idle على القيمة Auto. لكن بدل تغيير الخاصية Advanced>Mode إلى Enabled، أضفنا شرطًا من خلال ضبط قيمة الخاصية Condition على jumping. وبشكل مشابه، نضع شرطًا على تنفيذ الانتقال بين Jump_Idle و Jump_Land هو grounded؛ وسنتمكن من ضبط هذه الشروط لاحقًا من خلال الشيفرة لتفعيل عملية الانتقال. قد نلاحظ أخيرًا إن أمعنا النظر أن الانتقال بين الحركتين Jump_Land و IWR لا تبدو سلسلة، لأن آخر إطار من الأولى لا يتوافق تمامًا مع أول إطار من الثانية. يمكن أن نتدارك هذا الأمر باختيار الانتقال بينهما وضبط قيمة الخاصية Xfade Time على 0.1 وسنرى نتيجةً مرضية. خلاصة ضبطنا في هذا المقال حركات شخصية ثلاثية الأبعاد وأصبحت جاهزةً للاستخدام؛ وباستخدام العقدة AnimationTree أصبح اختيار الانتقال بين الحركات أكثر سهولةً في شيفرة الحركة. ترجمة -وبتصرف- للمقال: Character Animation. اقرأ أيضًا المقال السابق: استيراد الأصول Assets ثلاثية الأبعاد في محرك الألعاب Godot إعداد منطقة اللعب للعبة ثلاثية الأبعاد باستخدام جودو إنشاء شخصيات ثلاثية الأبعاد في جودو Godot تحريك اللاعب برمجيًا في لعبة ثلاثية الأبعاد باستخدام محرك جودو
-
نقدم في سلسلة المقالات التالية طرائق لاستيراد أصول الألعاب ثلاثية الألعاب والتعامل معها بما في ذلك النماذج Models والرسوم المتحركة Animations ومواد البناء Materials. سنستخدم في مثالنا الذي نبنيه لتوضيح الفكرة بعض الأصول المتاحة على موقع Kay Lousberg: حزمة شخصيات المغامرين Adventurers حزمة Dungeon عند استخدام حزم الأصول التي ذكرناها ببداية المقال، سنلاحظ أن هناك عدة نسخ من الأصول وبتنسيقات مختلفة مثل OBJ و FBX و GLTF، وأنه توجد ملفات إضافية مثل الأمثلة وملفات نقوش منفصلة في حال أردت تعديل أي شيء. وبالطبع لن نحتاج إلى كل ذلك، فالتنسيق GLTF هو الصيغة المفضلة للاستيراد في جودو. إذًا كل ما علينا فعله هو سحب المجلد gltf أو الملفات gltf. أو glb. وهي الصيغة الثنائية لنفس الملف، إلى مجلد مشروعك. سنختار المجلد gltf من حزمة Dungeon، والمجلد characters من حزمة Adventurers، ونسحبهما إلى مجلد مشروعنا ملاحظة: هنالك الكثير من الملفات في حزمة Dungeon وقد يستغرق جودو بعض الوقت في قراءتها. استيراد شخصية سنختار الملف Knight.glb من المجلد characters>gltf ضمن نافذة نظام الملفات FileSystem، ثم ننقر على نافذة استيراد Import في الأعلى إلى جوار نافذة المشهد Scene. سنجد ضمن النافذة بعض خيارات الاستيراد الأساسية، لكن بإمكاننا تفصيل العملية. سننقر على زر إعدادات متقدمة Advanced، وستظهر لنا نافذة جديدة. سنرى في اليمين (أو اليسار في النسخة الإنجليزية) البيانات التي يضمها مشهد GLTF بما في ذلك النقوش textures والحركات Animations؛ وسنلاحظ كيف اُلحقت كل الأسلحة بالشخصية، إضافةً إلى قائمة مطولة بالحركات التي يمكن للشخصية تنفيذها. تظهر الشخصية في وسط النافذة، إضافةً إلى مجموعة من الخيارات إلى اليسار (اليمين في النسخة الإنجليزية) التي تساعدنا على تعديل ضبط العنصر الذي نختاره من القائمة اليمينية. وطالما أننا سنكتب شيفرة اللاعب باعتباره عقدةً من النوع CharacterBody3D، فبإمكاننا تحديد نوع العقدة من خلال النقر على مشهد scene، ثم الانتقال إلى القائمة اليمينة والنقر إلى جوار الخاصية Root Type ثم اختيار العقدة CharacterBody3D من النافذة التي ظهرت. الحركات Animations سننتقل إلى الأسفل في نافذة الإعدادات المتقدمة نحو شجرة الحركات Animations، وسنجد الكثير منها. سنحتاج في مثالنا إلى تنفيذ بعض الحركات مرةً واحدةً فقط مثل الهجوم، في حين سننفذ بعضًا آخر في حلقات مثل المشي walk والتوقف دون فعل Idle والجري Running. ولتنفيذ أي حركة مستمرة بشكل حلقة، سنننقر عليها ثم نضبط الخاصية Loop Mode على Linear. سنكرر هذا الأمر على جميع حركات المشي Walking والجري Running والتوقف Idle المختلفة. وعند الانتهاء من ذلك سننقر على زر إعادة الاستيراد Reimport في أسفل النافذة. ملاحظة: إن كنا نصمم شخصياتنا بأنفسنا، نستطيع تجاوز عملية التعيين اليدوي لتكرار الحركة، والانتقال مباشرةً إلى تنفيذ الأمر تلقائيًا من خلال إضافة اللاحقة loop- إلى اسم الحركة. وللمزيد من التوضيح يمكن مراجعة توثيق جودو. سننقر الآن على الملف knight.glb بالزر اليمين للفأرة ونختار الأمر مشهد مورّث جديد New Inherited Scene. سنرى أن هناك عقدة جديدة ظهرت في نافذة المشهد تضم كل النماذج والحركات AnimationPlayer التي نستطيع تجريبها ضمن نافذة التحريك أسفل نافذة العرض. استيراد عناصر البيئة المحيطة يمكنننا استيراد عناصر البيئة المحيطة بنفس الأسلوب. لنستخدم كمثال عملية استيراد حائط من حزمة dungeon. ونظرًا لوجود الكثير من الملفات في الحزمة، سنكتب wall في مرشح البحث، لنجد المشهد المطلوب الآتي: نريد أن يكون الجدار صلبًا، وسيكون إنشاء جسم من النوع StaticBody3D بأنفسنا مضنيًا مع جميع أشكال التصادم collision shapes التي يجب تعيينها لكل عنصر من الجسم. لكن يستطيع جودو تنفيذ الأمر تلقائيًا عند استيراد العنصر. سننتقل إلى نافذة الإعدادات المتقدمة للمشهد المستورد، ثم نختار العقدة wall من النوع Mesh أيقونتها شبكة حمراء، ثم نفعّل الخيار فيزياء Physics وننتقل إلى الخاصية Physics>Shape Type ونضبطها على Simple Convex. يمثل هذا الخيار الشكل المحدب الذي يغلف الجدار الذي سيساعد في اكتشاف تصادم أي جسم مع الجدار. سننقر الآن على خيار إعادة الاستيراد Reimport، وهكذا سيُنشئ جودو عقدةً من النوع StaticBody3D عند استخدام هذا العنصر في اللعبة. ملاحظة: كما أشرنا سابقًا، هناك طريقة استيراد لتعيين أشكال التصادم تلقائيًا. لذا سنضيف في مشروع Blender اللاحقة col- مما يساعد البرنامج الذي يستورد الملف في تنفيذ العملية تلقائيًا. ويمكن مراجعة توثيق جودو. أتمتة الاستيراد على الرغم من أن إضافة تلميحات إلى أسماء الملفات المستوردة هي الطريقة المفضلة في أتمتة عملية الاستيراد، خاصةً عندما نصمم بأنفسنا الشخصيات والعناصر. لكنه أمر لا يمكن تنفيذه عند استيراد هذه الملفات من حزم جاهزة كالتي نستخدمها في مثالنا. بإمكاننا أيضًا كتابة سكريبت استيراد يُنفّذ على كل عقدة مستوردة من نوع معين، فنستطيع مثلًا أتمتة عملية إنشاء جسم التصادم التي شرحناها في الفقرة السابقة، حيث يتنقل السكريبت التالي خلال جميع عقد الكائن الذي استوردناه ويُنشئ جسم تصادم لكل عقدة من النوع MeshInstance3D @tool extends EditorScenePostImport func _post_import(scene): iterate(scene) return scene func iterate(node): if node != null: if node is MeshInstance3D: node.create_trimesh_collision() for child in node.get_children(): iterate(child) ننتقل في النافذة استيراد Import إلى الخاصية Import Script ثم نختار السكريبت السابق بعد حفظه في مكان مناسب. وهكذا سيُنشئ جودو أشكال تصادم لكل عقدة من النوع MeshInstance3D عند النقر على إعادة الاستيراد Reimport. خلاصة تعلمنا في هذا المقال طريقة استيراد أصول ثلاثية أبعاد جاهزة إلى محرك الألعاب جودو وضبط بعض الخيارات المهمة لعملية الاستيراد وكتابة سكريبت لأتمتتها. ترجمة -وبتصرف- للمقالين: Assets و Importing Assets اقرأ أيضًا المقال السابق: بدء وإنهاء الألعاب في محرك جودو Godot إعداد منطقة اللعب للعبة ثلاثية الأبعاد باستخدام جودو إنشاء شخصيات ثلاثية الأبعاد في جودو Godot تحريك اللاعب برمجيًا في لعبة ثلاثية الأبعاد باستخدام محرك جودو
-
سنكتشف في هذا المقال متى يدخل أو يخرج كائن من الشاشة، وسنتعرّف على كيفية إنشاء وتحريك شخصية في ألعاب المنصات ثنائية الأبعاد. إنشاء شخصية وتحريكها في ألعاب المنصات ثنائية الأبعاد سننشئ الأن شخصيةً في ألعاب المنصات ثنائية الأبعاد. يُفاجَأ المطورون المبتدئون في أغلب الأحيان بمدى تعقيد برمجة شخصيات ألعاب المنصات، لذا يوفر محرّك ألعاب جودو Godot بعض الأدوات المُضمَّنة للمساعدة، ولكن يمكن القول بأن هناك عددًا كبيرًا من الحلول التي تضاهي عدد الألعاب الموجودة. لن نتعمق في شرح الميزات مثل ميزات القفزات المزدوجة أو الانحناء أو القفز على الحائط أو الرسوم المتحركة، بل سنناقش أساسيات الحركة في ألعاب المنصات. ملاحظة: يمكن استخدام العقدة RigidBody2D لإنشاء شخصية لألعاب المنصات، ولكننا سنركز على استخدام العقدة CharacterBody2D، إذ تُعَد الأجسام الحركية Kinematic مناسبةً لألعاب المنصات، حيث لن تكون مهتمًا كثيرًا بالفيزياء الواقعية بقدر الاهتمام بالشعور المتجاوب في اللعبة. سنبدأ بعقدة CharacterBody2D، ونضيف إليها عقدتي Sprite2D و CollisionShape2D، ثم نرفق السكربت التالي بالعقدة الجذر للشخصية: extends CharacterBody2D @export var speed = 1200 @export var jump_speed = -1800 @export var gravity = 4000 func _physics_process(delta): # إضافة الجاذبية في كل إطار velocity.y += gravity * delta # يؤثر الدخل على المحور x فقط velocity.x = Input.get_axis("walk_left", "walk_right") * speed move_and_slide() # السماح بالقفز عند وجود الشخصية على الأرض فقط if Input.is_action_just_pressed("jump") and is_on_floor(): velocity.y = jump_speed يمكننا ملاحظة أننا نستخدم إجراءات الإدخال التي عرّفناها في خريطة الإدخال InputMap وهي: "walk_right" و "walk_left" و "jump"، لذا يمكن الاطلاع على إجراءات الإدخال InputActions لمزيد من التفاصيل. تعتمد القيم المُستخدَمة للسرعة speed والجاذبية gravity وسرعة القفز jump_speed اعتمادًا كبيرًا على حجم الشخصية الرسومية Sprite الخاصة باللاعب، وتكون خامة Texture اللاعب 108x208 بكسل في هذا المثال، وإذا كانت الصورة أصغر حجمًا، فستستخدم قيمًا أصغر. نريد أيضًا قيمًا عالية للشعور بالسرعة والاستجابة، إذ تؤدي الجاذبية المنخفضة إلى لعبة نشعر فيها بعدم التوازن؛ بينما تمثل القيمة العالية العودة إلى الأرض بسرعة والجاهزية للقفز مرةً أخرى. سنتحقق من التابع is_on_floor() بعد استخدام الدالة move_and_slide() التي تضبط قيمة هذا التابع، لذا يجب عدم التحقق منه قبل ذلك، وإلّا سنحصل على القيمة من الإطار السابق. الاحتكاك Friction والتسارع Acceleration تُعَد الشيفرة البرمجية السابقة بداية جيدة، إذ يمكننا استخدامها كأساس لمجموعة متنوعة من متحكمات المنصات، ولكنها تواجه مشكلة الحركة اللحظية Instantaneous Movement، ولكن يمكننا الحصول على شعور طبيعي أكثر من خلال تسارع الشخصية مثلًا حتى تصل إلى سرعتها القصوى، ثم تتوقف عند عدم وجود دخل. إحدى الطرق لإضافة هذا السلوك هي استخدام الاستيفاء الخطي Linear Interpolation أو "lerp"، بحيث ننتقل باستخدام الاستيفاء الخطي من السرعة الحالية إلى السرعة القصوى عند التحرك، وننتقل باستخدام الاستيفاء الخطي من السرعة الحالية إلى الصفر أثناء التوقف، وبالتالي سيوفر تعديل مقدار الاستيفاء الخطي مجموعة متنوعة من أنماط الحركة. ملاحظة: يمكن الاطلاع على مقال الاستيفاء للحصول على مزيد من المعلومات عن الاستيفاء الخطي. extends CharacterBody2D @export var speed = 1200 @export var jump_speed = -1800 @export var gravity = 4000 @export_range(0.0, 1.0) var friction = 0.1 @export_range(0.0 , 1.0) var acceleration = 0.25 func _physics_process(delta): velocity.y += gravity * delta var dir = Input.get_axis("walk_left", "walk_right") if dir != 0: velocity.x = lerp(velocity.x, dir * speed, acceleration) else: velocity.x = lerp(velocity.x, 0.0, friction) move_and_slide() if Input.is_action_just_pressed("jump") and is_on_floor(): velocity.y = jump_speed سنجرب هنا تغيير قيم الاحتكاك friction والتسارع acceleration لمعرفة مدى تأثيرها على شعورنا أثناء اللعب. فقد يحتاج مستوى الجليد إلى قيم منخفضة جدًا مثلًا، مما يجعل الحركة أصعب. تمنحنا هذه الشيفرة البرمجية نقطة بداية لبناء متحكم منصات خاص بك، لذا يمكن الاطلاع على المقالات اللاحقة لمزيد من ميزات المنصات المتقدمة مثل القفز على الحائط. تحديد متى يدخل كائن إلى الشاشة أو يغادرها يوفر محرّك الألعاب العقدة VisibleOnScreenNotifier2D، حيث إذا أرفقنا هذه العقدة بالكائن، فسنتمكّن من استخدام إشارات screen_entered و screen_exited الخاصة بها. تطبيق عملي 1 ليكن لدينا مثلًا مقذوف يتحرك في خط مستقيم بعد إطلاقه، وإذا استمرينا في الإطلاق، فسنحصل في النهاية على عدد كبير من الكائنات التي يتعقّبها المحرّك، حتى وإن كانت هذه الكائنات خارج الشاشة، مما قد يتسبب في حدوث تأخير. توضح الشيفرة البرمجية التالية حركة المقذوف: extends Area2D var velocity = Vector2(500, 0) func _process(delta): position += velocity * delta يمكن حذف المقذوف تلقائيًا عند تحرّكه خارج الشاشة من خلال إضافة العقدة VisibleOnScreenNotifier2D والاتصال بإشارة screen_exited الخاصة بها. func _on_VisibleOnScreenNotifier2D_screen_exited(): queue_free() تطبيق عملي 2 ليكن لدينا مثلًا عدو ينفّذ بعض الإجراءات مثل التحرك على طول مسار أو تشغيل رسوم متحركة، ولن يظهر على الشاشة سوى عدد قليل من الأعداء في الوقت نفسه في خريطة كبيرة تحتوي على العديد من الأعداء، إذ يمكننا تعطيل إجراءات العدو أثناء وجوده خارج الشاشة باستخدام العقدة VisibleOnScreenNotifier2D كما هو الحال في جزء الشيفرة البرمجية التالي: var active = false func _process(delta): if active: play_animation() move() func _on_VisibleOnScreenNotifier2D_screen_entered(): active = true func _on_VisibleOnScreenNotifier2D_screen_exited(): active = false الخاتمة استعرضنا في هذا المقال كيفية استخدام العقدة VisibleOnScreenNotifier2D لتحديد متى يدخل كائن إلى الشاشة أو يغادرها، مما يساعد في تحسين أداء اللعبة من خلال إدارة الكائنات غير المرئية بكفاءة؛ كما تناولنا أساسيات إنشاء شخصية وتحريكها في ألعاب المنصات ثنائية الأبعاد باستخدام العقدة CharacterBody2D، مع التركيز على مفاهيم مثل الجاذبية، القفز، والتسارع للحصول على حركة سلسة واستجابة طبيعية. يمكن تنزيل شيفرة المشروع البرمجية من Github للاطلاع عليه وفهمه أكثر. ترجمة -وبتصرّف- للقسمين Entering/Exiting the screen و Platform character من توثيقات Kidscancode. اقرأ أيضًا فهم RayCast2D واستخداماتها في محرك ألعاب جودو سحب وإفلات جسم صلب RigidBody2D في جودو إنشاء شخصيات ثلاثية الأبعاد في جودو Godot دليلك الشامل إلى برمجة الألعاب ألعاب الفيديو: تطورها وأهميتها وخطوات برمجتها برمجة لعبة متاهة باستخدام محرك يونيتي Unity
-
هيئنا في مقال إنشاء لعبة سفينة فضاء ثنائية الأبعاد في جودو الإعدادات اللازمة للعبة مركبة الفضاء المقاتلة ونزلنا الأيقونات والصور اللازمة وأصبحنا جاهزين لتصميم مشهد مركبة الفضاء الخاضعة لتحكم اللاعب. إعداد مشهد سفينة الفضاء من الأقسام الشائعة في مجرى بناء ألعاب جودو هو إنشاء المشاهد. وكما رأينا سابقًا، يمثل المشهد في جودو مجموعة من العقد. وفي معظم ألعاب جودو، يُهيئ كل كائن في اللعبة على شكل مشهد يضم عقدًا تعطيه الوظائف المنوطة به، وقد بعض الشيفرة لتخصيص سلوك هذا الكائن. اختيار العقد علينا في البداية اتخاذ قرار بشأن العقد التي يجب أن نبدأ بها المشهد، حيث تًدعى العقدة الأولى التي تضيفها إلى المشهد بالعقدة الجذرية root node، وينبغي أن تكون هذه العقدة هي العقد الأساسية للمشهد بحيث تعرّف سلوك كائن اللعبة. وسنضيف بعض ذلك عقدًا أبناء إلى هذه العقدة معطيةً الكائن وظائفه. ما هي العقدة التي ستكونها السفينة إذًا؟ دعونا نحلل المتطلبات، ونرى ما هي العقد التي ستكون مفيدة في تحقيق تلك المتطلبات. تحتاج سفينة الفضاء إلى: الحركة في فضاء ثنائي البعد: تكفينا عقدة Node2D أساسية، وهي عقدة تمتلك خاصيات موقع position و دوران rotation وغيرها من خاصيات الفضاء 2D عرض صورة: وتناسب هذه الوظيفة العقدة Sprite2D، وهي أيضًا عقدة من النوع Node2D لهذا يمكن التحكم بها وتحريكها اكتشاف تصادمها بأجسام أخرى: سيتحرك الأعداء ويطلقون النار على السفينة، لذا علينا معرفة متى تُصاب السفينة. ستكون العقدة Area2D مثالية، إذ يمكنها اكتشاف التلامس مع كائنات أخرى ولها خاصيات موقع، وليس لها مظهر بحد ذاتها بالنظر إلى تلك المتطلبات سنجد أن Area2D هي من تقدم الوظيفة الأساسية وتعرض العقدة Sprite2D شكل السفينة وهذا كل ما نحتاجه. بناء المشهد ننقر على زر + أو زر عقدة أخرى Other Node في النافذة الفرعية التي يُطلَق عليها تسمية المشهد Scene، ونشرع بكتابة Area2D لتظهر لنا العقدة في القائمة، فنختارها. وعندما نرى العقدة في نافذة المشهد، سننقر على العقدة ونسمّيها Player، ثم نحفظ المشهد باستخدام الاختصار <Ctrl+S>. عرض سفينة الفضاء سنختار العقدة Player ثم نضيف عقدة Sprite2D ونسمّيها Ship حتى نبقي الأمور أكثر تنظيمًا؛ كما سنسحب بعد ذلك الأيقونة Player_ship (16x16).png من موقعها في نافذة الملفات إلى مربع الخاصية ملمس Texture في نافذة الفاحص Inspector. قد نلاحظ مباشرةً وجود ثلاثة مركبات في الصورة، والسبب أن الصورة تضم نسخًا من المركبة التي تتحرك يمينًا ويسارًا، لذا سنستخدم ذلك في خاصيات التحريك Animation من الفاحص. سننقر على خاصية التحريك Animation ثم على الخيار إطارات افقية Hframes ونضبطه على الرقم 3، وهكذا سنتنتقل بين النسخ الثلاث للصورة عند تغيير قيمة الخاصية إطار Frame. سنترك الآن قيم هذه الخاصية على 1. إضافة شكل التصادم سنلاحظ وجود إشارة تحذير صفراء إلى جانب العقدة Area2D، بحيث إذا نقرنا عليها سنجد أنها تنبهنا بأن العقدة لا شكل لها، ولهذا علينا تعريف شكل لها. سنعرّف شكل العقدة بإضافة عقدة أخرى من النوع CollisionShape2D كابن للعقدة Player. سنجد في نافذة الفاحص عند النقر على العقدة CollisionShape2D الخاصية Shape التي تأخذ قيمة افتراضية فارغة <empty>، وبالنقر على الصندوق المجاور، ستظهر قائمة بكل الأشكال المتاحة، سنختار منها New RectangleShape2Dوسنجد مربعًا باللون الأزرق الفاتح قد أحاط بالسفينة. بإمكاننا ضبط حجم الشكل الذي ظهر بسحب الدوائر البرتقالية وتحريكها، أو بالنقر على النقر زر السهم إلى جوار صندوق عقدة الشكل في نافذة الفاحص، ثم اختيار shape ووضع الأبعاد يدويًا. العادم Exhaust ستبدو السفينة أكثر ديناميكيةً وواقعيةً في حال أضفنا بعض الحركة إليها، وسنجد في مجلد الأيقونات بعض الرسوميات التي تمثل العادم أو اللهب الناري الذي يخرج من محركات السفينة ولها الاسم Boosters. سنجد أيضًا ثلاث أيقونات لنسخ أيقونات السفينة الثلاث هي اليمين واليسار والأمام. ولعرض هذه الأيقونات، سنختار العقدة Ship ونضيف إليها عقدة من النوع AnimatedSprite2D ونسميها Boosters. سننتقل بعد ذلك إلى الفاحص ومن ثم إلى شجرة التحريك Animation، وبعدها إلى الخاصية SpriteFrames التي تأخذ افتراضيًا قيمة فارغة <empty>، وننقر عليها لإضافة إطار رسومي جديد New SpriteFrames لننقر بعدها على الخيار SpriteFrames لفتح لوحة التحريك أسفل نافذة المحرر. سننقر الآن نقرًا مزدوجًا على العنصر default ونغير اسمه إلى forward. ولإضافة صور الرسوم المتحركة الآن، علينا النقر على زر إضافة إطارات من الملخص Add frames from sprite sheet الموضح في الصورة التالية بموضع مؤشر الفأرة: سنختار الآن الصورة Boosters (16 x 16).png وستظهر لنا نافذة تحديد الإطارات Select frames مباشرةً كي نختار الإطارات التي نريد. هناك إطاران فقط في الصورة، لكن الشبكة غير متناسقة، لذا سنغيّر قيمة الخاصية Size لتتلائم مع أبعاد الصورة 16x 16، ثم ننقر على كلا الإطارين لاختيارهما وبعدها على زر إضافة إطارين (Add 2 Frame(s. والآن، بعد أن أضفنا الإطارين، سننقر على زر التشغيل Play لتحريك الرسوم، وبإمكاننا أيضًا تفعيل الخيار Autoplay on Load كي يجري تحريك الصورة تلقائيًا. قد نجد هنا أن سرعة تغير اللهب بطيئة، لهذا يمكننا تعديل السرعة لتصبح 5FPS، بعدها سنكرر تنفيذ الخطوات السابقة لإضافة رسم متحرك للهب العادم عندما تتحرك إلى اليمين واليسار ونسميهما left and right. التحكم بسرعة الإطلاق على العدو آخر ما سنفعله لإكمال شخصية اللاعب هو إضافة عقدة Timer للتحكم بسرعة إطلاقه للنار، حيث سنضيف هذه العقدة كابن للعقدة Player ونسميها GunCooldown، وبعدها سنفعّل الخاصية One Shot في نافذة الفاحص كي لا يُفعّل المؤقت تلقائيًا عندما ينتهي من العد. سنفعّل المؤقت برمجيًا في الشيفرة عندما يطلق اللاعب النار، ولن يتمكن من إعادة الإطلاق مجددًا حتى ينتهي المؤقت من العد. الخاتمة إلى هنا نكون قد انتهينا من إعداد مشهد اللاعب في لعبتنا ثنائية الأبعاد في جودو، إذ أضفنا عقدًا تعطي سفينة اللاعب الوظائف التي تحتاجها في اللعبة، وسنضيف في المقال التالي الشيفرة البرمجية اللازمة للتحكم بالسفينة وإطلاق النار، واكتشاف تصادمها مع كائنات أخرى في اللعبة. ترجمة -وبتصرف- للمقال: Designing the Player Scene. اقرأ أيضًا إنشاء لعبة سفينة فضاء ثنائية الأبعاد في جودو تحريك سفينة فضاء باستخدام RigidBody2D في جودو برمجة صاروخ موجه يتبع هدف متحرك عبر محرك الألعاب Godot
-
سنوضّح في هذا المقال كيفية إنشاء وحدة تحكم للسيارات في الألعاب ثنائية الأبعاد من الأعلى إلى الأسفل. قد لا يتمكّن المبتدئون من إنشاء شيءٍ يتعامل مع لعبةٍ مشابهة لسيارة حقيقية، لذا سنذكر بعض الأخطاء الشائعة التي قد تظهر في ألعاب السيارات للهواة: لا تدور السيارة حول مركزها، إذ لا تنزلق Slide عجلات السيارة الخلفية من جانب إلى آخر إلّا في حالة الانجراف Drifting، وسنتحدث عن ذلك لاحقًا لا يمكن للسيارة أن تستدير إلا عندما تتحرك، إذ لا يمكنها الدوران في مكانها السيارة ليست قطارًا، فهي ليست على قضبان سكة حديدية، لذا يجب أن تتضمن الاستدارة بسرعات عالية بعض الانزلاق أو الانجراف توجد العديد من الأساليب لفيزياء السيارات ثنائية الأبعاد، وتعتمد بصفة أساسية على مدى الواقعية التي نريد أن تكون عليها، لذا نريد الوصول في هذا المقال إلى مستوى معين من الواقعية. ملاحظة: تعتمد الطريقة التي سنوضّحها فيما يلي على الخوارزمية الموجودة في مقال فيزياء توجيه السيارات في الألعاب ثنائية الأبعاد البسيطة. تُقسَم الطريقة المتبعة فيما يلي إلى 5 أجزاء، حيث يضيف كل جزء منها ميزة مختلفة لحركة السيارة، لذا يمكن المزج بينها وفقًا لاحتياجاتنا. إعداد المشهد فيما يلي إعداد لمشهد السيارة: CharacterBody2D Sprite2D CollisionShape2D Camera2D سنضيف أي خامة شخصية رسومية Sprite Texture نريدها، حيث سنستخدم في مثالنا حزمة السباق من موقع Kenney. تُعَد العقدة CapsuleShape2D خيارًا جيدًا للتصادم، بحيث لا تكون للسيارة زوايا حادة قد تتسبب في تعطلها بسبب العوائق. سنستخدم أيضًا أربعة إجراءات إدخال هي: steer_right و steer_left و accelerate و brake، لذا يمكننا ضبطها على أيّ مفاتيح إدخال نفضلها. الجزء الأول: الحركة تتمثل الخطوة الأولى في برمجة الحركة بناءً على الخوارزمية السابقة، لذا سنبدأ ببعض المتغيرات كما يلي: extends CharacterBody2D var wheel_base = 70 # المسافة من العجلة الأمامية إلى العجلة الخلفية var steering_angle = 15 # مقدار دوران العجلة الأمامية بالدرجات var steer_direction نضبط المتغير wheelbase على قيمة تتوافق مع شخصيتنا الرسومية، ويمثّل المتغير steer_direction مقدار دوران العجلات. ملاحظة: نستخدم في مثالنا عناصر تحكم لوحة المفاتيح، لذا سيحدث الدوران أو لن يحدث على الإطلاق، ولكن إذا كنا تستخدم عصا تحكم للعب، فيمكننا بدلًا من ذلك تغيير هذه القيمة بناءً على المسافة التي تتحركها العصا. func _physics_process(delta): get_input() calculate_steering(delta) move_and_slide() يجب التحقق من الإدخال وحساب التوجيه Steering في كل إطار، ثم نمرّر السرعة velocity الناتجة إلى الدالة move_and_slide()، ونعرّف الدالتين التاليتين كما يلي: func get_input(): var turn = Input.get_axis("steer_left", "steer_right") steer_direction = turn * deg_to_rad(steering_angle) velocity = Vector2.ZERO if Input.is_action_pressed("accelerate"): velocity = transform.x * 500 سنتحقق من إدخال المستخدم ونضبط السرعة. وكما نلاحظ فقيمة السرعة 500 مؤقتة حتى نتمكّن من اختبار الحركة، وسنعالجها في الجزء التالي. سننفّذ بعد ذلك الخوارزمية السابقة كما يلي: func calculate_steering(delta): # 1. العثور على مواضع العجلات var rear_wheel = position - transform.x * wheel_base / 2.0 var front_wheel = position + transform.x * wheel_base / 2.0 # 2. تحريك العجلات للأمام rear_wheel += velocity * delta front_wheel += velocity.rotated(steer_direction) * delta # 3. العثور على متجه الاتجاه الجديد var new_heading = rear_wheel.direction_to(front_wheel) # 4. ضبط السرعة والدوران على الاتجاه الجديد velocity = new_heading * velocity.length() rotation = new_heading.angle() سنشغّل الآن المشروع ويجب أن تتحرك وتدور السيارة، ولكن لا تزال الحركة غير طبيعية؛ إذ ستبدأ السيارة بالحركة وتتوقف مباشرةً، ويمكن إصلاح ذلك من خلال إضافة التسارع إلى العملية الحسابية. الجزء الثاني: التسارع Acceleration سنحتاج إلى متغير إعدادٍ آخر ومتغيرًا لتتبّع التسارع الكلي للسيارة كما يلي: var engine_power = 900 # قوة التسارع للأمام var acceleration = Vector2.ZERO علينا الآن التعديل شيفرة الإدخال البرمجية لتطبيق التسارع بدلًا من تغيير سرعة velocity السيارة مباشرةً كما يلي: func get_input(): var turn = Input.get_axis("steer_left", "steer_right") steer_direction = turn * deg_to_rad(steering_angle) if Input.is_action_pressed("accelerate"): acceleration = transform.x * engine_power يمكن تطبيق التسارع على السرعة بعد الحصول عليه كما يلي: func _physics_process(delta): acceleration = Vector2.ZERO get_input() calculate_steering(delta) velocity += acceleration * delta move_and_slide() يجب الآن أن تزيد السيارة من سرعتها تدريجيًا عند تشغليها، ولكن ليس لدينا أيّ طريقة لإبطاء السرعة بعد. الجزء الثالث: الاحتكاك Friction/السحب Drag تتعرض السيارة لقوتين مختلفتين لإبطاء السرعة هما: الاحتكاك والسحب. الاحتكاك هو القوة التي تطبقها الأرض، وتكون مرتفعةً جدًا عند القيادة على الرمال، ومنخفضةً جدًا عند القيادة على الجليد، ويتناسب الاحتكاك مع السرعة، فكلما زادت السرعة، زادت هذه القوة؛ أما السحب، فهو القوة الناتجة عن مقاومة الرياح، ويعتمد على المقطع العرضي للسيارة، إذ تتعرض الشاحنة الكبيرة أو الشاحنة الصغيرة لسحب أكبر من سيارة السباق، ويتناسب السحب مع مربع السرعة. يكون الاحتكاك ملحوظًا عند التحرك ببطء، ولكن يغلب السحب عند السرعات العالية. سنضيف كلتا هاتين القوتين إلى العمليات الحسابية الخاصة بنا. ستمنح قيم هاتين القوتين سيارتنا أقصى سرعة، وهي النقطة التي لا تستطيع فيها قوة المحرّك التغلب على قوة السحب. سنوضح فيما يلي القيم الابتدائية لهاتين الكميتين: var friction = -55 var drag = -0.06 تعني هذه القيم أن قوة السحب تتغلب على قوة الاحتكاك عند السرعة 600 كما نرى في هذا الرسم البياني: ملاحظة: يمكن تعديل هذه القيم لمعرفة كيفية تغيرها. سنستدعي في الدالة _physics_process() دالةً لحساب الاحتكاك الحالي ونطبقّه على قوة التسارع كما يلي: func _physics_process(delta): acceleration = Vector2.ZERO get_input() apply_friction(delta) calculate_steering(delta) velocity += acceleration * delta velocity = move_and_slide(velocity) func apply_friction(delta): if acceleration == Vector2.ZERO and velocity.length() < 50: velocity = Vector2.ZERO var friction_force = velocity * friction * delta var drag_force = velocity * velocity.length() * drag * delta acceleration += drag_force + friction_force سنحدد أولًا السرعة الدّنيا، مما يضمن عدم استمرار السيارة في التحرك للأمام بسرعات منخفضة للغاية، لأن الاحتكاك لا يؤدي إلى خفض السرعة إلى الصفر أبدًا؛ ونحسب بعد ذلك القوتين ونضيفهما إلى التسارع الكلي، وستؤثران على السيارة في الاتجاه المعاكس لأن قيمتهما سالبة. 02_car_friction.webm الجزء الرابع: الرجوع للخلف/الفرامل Brake نحتاج إلى متغيرين آخرين للإعدادات: var braking = -450 var max_speed_reverse = 250 سنضيف الآن الدخل إلى الدالة get_input() كما يلي: if Input.is_action_pressed("brake"): acceleration = transform.x * braking يُعَد ذلك جيدًا للتوقف، ولكننا نريد أيضًا أن نتمكن من وضع السيارة في وضع الرجوع للخلف، ولكن ذلك لن ينجح حاليًا، لأن التسارع يُطبَّق في اتجاه التوجّه دائمًا الذي هو إلى الأمام، لذا نحتاج إلى تسارع عكسي عند الرجوع للخلف. func calculate_steering(delta): var rear_wheel = position - transform.x * wheel_base / 2.0 var front_wheel = position + transform.x * wheel_base / 2.0 rear_wheel += velocity * delta front_wheel += velocity.rotated(steer_angle) * delta var new_heading = (front_wheel - rear_wheel).normalized() var d = new_heading.dot(velocity.normalized()) if d > 0: velocity = new_heading * velocity.length() if d < 0: velocity = -new_heading * min(velocity.length(), max_speed_reverse) rotation = new_heading.angle() يمكننا معرفة ما إذا كنا نتسارع للأمام أم للخلف باستخدام حاصل الضرب النقطي Dot Product، حيث إذا كان المتجهان متحاذيين، فستكون النتيجة أكبر من 0، وإذا كانت الحركة في الاتجاه المعاكس لاتجاه السيارة، فسيكون حاصل الضرب النقطي أقل من 0 ويجب أن نتحرك للخلف. 03_car_reverse.webm الجزء الخامس: الانجراف Drift والانزلاق Slide يمكن التوقف عند هذه النقطة وسنحظى بتجربة قيادة جيدة، ولكن ستبقى السيارة أشبه بأنها تسير على قضبان سكة حديدية، إذ تكون المنعطفات مثالية حتى عند السرعة القصوى، وكأنّ لإطارات السيارة تحكّم مثالي. لحل المشكلة، يجب أن تتسبب قوة الانعطاف عند السرعات العالية أو حتى المنخفضة إذا رغبنا في ذلك، في انزلاق الإطارات مما يؤدي إلى حركة انزلاقية، وسنفعل ذلك الآتي: var slip_speed = 400 # السرعة عند تقليل الشدّ Traction var traction_fast = 2.5 # الشد عالي السرعة var traction_slow = 10 # الشد منخفض السرعة سنطبّق هذه القيم عند حساب التوجيه، إذ تُضبَط السرعة مباشرةً على الاتجاه الجديد حاليًا، ولكننا سنستخدم بدلًا من ذلك الاستيفاء باستخدام الدالة lerp() لجعلها تدور جزئيًا نحو الاتجاه الجديد فقط، وستحدّد قيم الشدّ Traction مدى تماسك الإطارات. func calculate_steering(delta): var rear_wheel = position - transform.x * wheel_base / 2.0 var front_wheel = position + transform.x * wheel_base / 2.0 rear_wheel += velocity * delta front_wheel += velocity.rotated(steer_angle) * delta var new_heading = (front_wheel - rear_wheel).normalized() # اختر قيمة الشد التي تريد استخدامها، ويجب أن يكون الانزلاق منخفضًا عند السرعات المنخفضة var traction = traction_slow if velocity.length() > slip_speed: traction = traction_fast var d = new_heading.dot(velocity.normalized()) if d > 0: velocity = lerp(velocity, new_heading * velocity.length(), traction * delta) if d < 0: velocity = -new_heading * min(velocity.length(), max_speed_reverse) rotation = new_heading.angle() نختار قيمة الشد التي نريد استخدامها ونطبق الدالة lerp() على السرعة velocity. 04_car_drift.webm التعديلات Adjustments لدينا في هذه المرحلة عدد كبير من الإعدادات التي تتحكّم في سلوك السيارة، ويمكن أن يؤدي تعديلها إلى تغيير جذري في كيفية قيادة السيارة. سننزّل المشروع الخاص بهذا المقال لتسهيل تجربة قيم مختلفة. سنرى عند تشغيل اللعبة مجموعةً من أشرطة التمرير التي يمكن استخدامها لتغيير سلوك السيارة أثناء القيادة، ويمكن الضغط على <Tab> لإظهار أو إخفاء لوحة أشرطة التمرير. الخاتمة بهذا نكون قد وصلنا لنهاية مقالنا الذي استعرضنا فيه خطوات إنشاء وحدة تحكم لسيارات الألعاب ثنائية الأبعاد بأسلوب واقعي بدءًا من الحركة الأساسية، مرورًا بالتسارع والفرملة، وصولًا إلى الانجراف والانزلاق. يمكن اعتبار هذا المقال بمثابة أساس لتطوير تجربة قيادة أكثر ديناميكية وواقعية في الألعاب. وللتذكير، يمكن تنزيل شيفرة المشروع البرمجية كاملة من Github. ترجمة -وبتصرّف- للقسم Car steering من توثيقات Kidscancode. اقرأ أيضًا المقال السابق: آلية الالتفاف حول الشاشة وتحريك الشخصيات في الألعاب ثنائية الأبعاد إنشاء شخصية وتحريكها في ألعاب المنصات ثنائية الأبعاد دليلك الشامل إلى برمجة الألعاب ألعاب الفيديو: تطورها وأهميتها وخطوات برمجتها
-
سنتعرف في هذا المقال وهو جزء من سلسلة دليل جودو، على الخطوة الأخيرة في بناء لعبة سفينة الفضاء ثنائية الأبعاد، وهي كيفية إضافة زر لبدء اللعبة وإعلان نهاية اللعبة Game Over وهو ما سنتعلمه في مقال اليوم. إطلاق اللعبة عندما نشغّل اللعبة حاليًا من داخل محرك الألعاب جودو، سيبدأ اللعب مباشرةً، ولهذا سنضيف زرًا كي لا تبدأ اللعبة إلا بعد النقر عليه. لتحقيق ذلك، سنضع زر البدء في مكان مركزي في واجهة اللعبة ونمكّن اللاعب من النقر عليه لبدء اللعبة. يمكن تحقيق ذلك باستخدام عقدة CenterContainer وCanvasLayer، وضبط إعدادات التموضع Layout لضمان أن الزر سيكون في مركز الشاشة ويستجيب جيدًا على مختلف أحجام الشاشات. سنضيف عقدةً من النوع CenterContainer إلى المشهد الرئيسي وكابن للعقدة CanvasLayer، ونضبط الخاصية Layout>Anchor Preset>Layout Mode لتكون قيمتها على كامل المستطيل Full Rect. سنضيف بعد ذلك عقدة ابن من النوع TextureButton ونسمّيها Start، ثم نضع الصورة START (48 x 8).png في الخاصية Textures>Normal لهذا الزر. سنضيف مرجعًا إلى الزر في أعلى سكريبت المشهد الرئيسي كما يلي: @onready var start_button = $CanvasLayer/CenterContainer/Start والآن سنصل الإشارة pressed الخاصة بالزر إلى العقدة Main ثم نضيف الشيفرة التالية: func _on_start_pressed(): start_button.hide() new_game() تعالج الدالة ()new_game عملية إطلاق اللعبة، ولهذا سنعدّل مضمون الدالة ()ready_ كي تعرض زر البدء بدلًا من نشر الأعداء: func _ready(): start_button.show() # spawn_enemies() سنضيف الآن الدالة ()new_game إلى السكريبت الرئيسي على النحو الآتي: func new_game(): score = 0 $CanvasLayer/UI.update_score(score) $Player.start() spawn_enemies() يجب أن يظهر زر البداية الآن عند تشغيل اللعبة، وسيؤدي الضغط عليه إلى انطلاقها إنهاء اللعبة من أجل إنهاء اللعبة، ومنحها الشكل النهائي بالعبارة الشهيرة Game Over، سنحتاج إلى إضافة عقدة ابن من النوع TextureRect إلى العقدة CenterContainer، وسنسميها GameOver. سنستخدم الصورة GAME_OVER (72 x 8).png كخلفية لها، وستتداخل هذه العقدة مع عقدة زر البداية، لكن لن يؤثر ذلك على اللعبة لأن كلًا منهما سيظهر في توقيت مختلف. سنضيف الآن مرجعًا آخر إلى أعلى السكريبت الرئيسي كما يلي: @onready var game_over = $CanvasLayer/CenterContainer/GameOver نضيف بعد ذلك الدالة ()game_over.hide إلى الدالة ()ready_، ثم نصل إشارة اللاعب died إلى Main: func _on_player_died(): get_tree().call_group("enemies", "queue_free") game_over.show() await get_tree().create_timer(2).timeout game_over.hide() start_button.show() وهنا ستعرض الشيفرة السابقة عبارة Game Over لمدة ثانيتين، ثم تعود باللاعب إلى زر البداية كي يتمكن من إعادة اللعبة. الخلاصة بهذا نكون قد أنهينا بناء لعبة بسيطة ثنائية الأبعاد عبر محرك الألعاب جودو، وإن أردت الاطلاع على المزيد من الأفكار والتقنيات في استخدام محرّك الألعاب جودو، وكيفية تسخيرها في تحويل أفكارك إلى ألعاب احترافية ننصحك بالاطلاع على دورة تطوير الألعاب باستخدام محرك جودو التي تقدمها أكاديمية حسوب التي تنطلق بك من الصفر وحتى الاحتراف من خلال دروس فيديو أعدها وقدمها مطورون خبراء في هذا المجال وباللغة العربية. وإن كنت تشعر أنك فهمت التقنيات التي ناقشناها في هذه السلسلة، فأنت مستعد لتطوير اللعبة أكثر. لهذا، حاول إضافة ميزة واحدة جديدة إلى اللعبة، ونقترح عليك بعضًا منها: إضافة أنواع مختلفة من الأعداء: ستجد أيقونات مختلفة للأعداء استفد من ذلك مجموعات من الأعداء: أضف مجموعة أخرى من الأعداء عندما تنتهي من المجموعة الأولى كبير الأعداء: جرب أن تضيف عدوًا أكبر حجماً من الأعداء العاديين معززات القوة: جرب عرض جوائز تعزز قوة المركبة الفضائية، وفكر كيف ستجمعها إعادة شحن الدرع: أضف معززات لتدعيم الدرع الذي يحمي السفينة الفضائية تطوير القذائف: أضف أنواعًا أخرى من القذائف إضافة مؤثرات صوتية وأصوات: يمكنك إضافة تأثيرات صوتية إلى اللعبة لجعلها أكثر جاذبية ترجمة -وبتصرف للمقالين: Starting and Ending the Game و Wrapping up. اقرأ أيضًا المقال السابق: بناء واجهة المستخدم وعارض النتيجة في لعبة السفينة الفضائية إنشاء مشهد إطلاق العدو للنشر في لعبة السفينة الفضائية إنشاء المشهد الرئيسي للعبة ثنائية الأبعاد عبر محرك Godot تصميم مشهد اللاعب في لعبة ثنائية الأبعاد باستخدام Godot تعرف على العقد Nodes في محرك ألعاب جودو Godot
-
سنشرح في هذا المقال وهو جزء من سلسلة دليل جودو، كيفية بناء واجهة المستخدم وعارض النتيجة في الألعاب ثنائية الأبعاد عبر محرك Godot. آخر الأقسام الرئيسية التي علينا بناؤها في لعبتنا ثنائية الأبعاد هي واجهة المستخدم User Interface؛ إذ سنحتاج إلى طريقة لعرض نتيجة اللاعب وغيرها من المعلومات. ولتنفيذ الأمر، سنستخدم عقدةً مختلفة من النوع Control التي يزودنا بها محرك الألعاب جودو لبناء واجهات المستخدم. مشهد واجهة المستخدم سنبدأ المشهد بعقدة من النوع MarginContainer ونسميها UI، حيث تضمن الحاوية MarginContainer ألا يقترب العقد الأبناء كثيرًا من الحافة من خلال إضافة هوامش حولها. سننقر الآن على العقدة UI وننتقل إلى نافذة الفاحص Inspector، ثم نضبط قيم الهوامش الأربعة على 10 ضمن الخاصية Theme Overrides>Constants. ننتقل بعدها إلى نافذة العرض الثنائي وننقر على أيقونة تجهيزات المراسي Anchor Preset وأيقونة المرساة Anchor، ثم نختار موقع الحاوية ليكون في أعلى الشاشة وعلى اتساعها بالعرض بالأعلى Top Wide. نضيف تاليًا عقدةً من النوع HBoxContainer، وهي نوع من الحاويات التي تنظم الأبناء أفقيًا. سنضيف ضمن هذه العقدة عقدة ابن من النوع TextureProgressBar، وهي شريط تقدم يمثل وضع درع المركبة الفضائية، وسنسمي هذه العقدة ShieldBar. لا تضم مجموعة الصور التي نزّلناها في مشروعنا أية صورة مناسبة لشريط التقدم، لذلك سننزلّ الصورتين التاليتين ونحفظهما في مجلد اللعبة. ضع صورة الخلفية الخضراء كقيمة للخاصية Texture>Progress، والصورة البيضاء كقيمة للخاصية Texture>Under. سنلاحظ مباشرة أن الشريط صغير جدًا، لذلك سنغير قيمة الخاصية Minimum Custom Size لتصبح (80,60) وسنرى أن المستطيل البرتقالي قد كبر. وكما هو واضح، لن يكون تمدد الصورة جميلًا، وقد يبدو سيئًا أيضًا، لهذا سنفعّل الخيار Range>Nine Patch Stretch ونضبط بعدها قيم الخاصية Stretch Margin الأربعة التي ستظهر على 3. من المفترض أن نرى الآن شريطًا طويلًا فارغًا. ولمعرفة كيف سيبدو عندما يبدأ بالامتلاء، سنغير قيمة الخاصية Range>Value إلى أي قيمة بين 0 و 100. سنعرض إلى اليسار نتيجة اللاعب، وقد نستخدم عقدةً من النوع Label وخط مناسب. وبالطبع لن تكون الخطوط التي يقدمها نظام التشغيل مناسبةً للعبة، ولهذا سنستخدم أيقونات الأرقام الموجودة في مجلد الصور، وذلك بعد معالجتها عن طريق الشيفرة لعرضها بالشكل الصحيح. إنشاء عداد للنتيجة سننشئ مشهدًا جديدًا أساسه عقدة من النوع HBoxContainer ونسيمها ScoreCounter. سنضبط هذه العقدة ليكون مركز ارتكاز العقدة بالعرض بالأعلى Top Wide، أي أن العقدة ستمتد أفقيًا وتتموضع عند الجزء العلوي من المشهد، ومحاذاتها نهاية الإنكماش End، أي ستكون محاذاة العقدة على الطرف الأيمن أو نهاية المساحة المتاحة باستخدام الزر المجاورة لأيقونة المرساة. سنضبط أيضًا الخاصية Theme Overrides>Constants>Separation على القيمة 0، وهنا علينا تفعيل الخيار إلى جانب الخاصية. سنضيف الآن مجموعةً من العقد النصية من النوع TextureRect ضمن الحاوية الأفقية لعرض الأرقام، وسنضيف عقدةً واحدة أولًا ثم نضاعف العدد. سنسمي العقدة النصية Digit0 ثم ننتقل إلى الفاحص، فالخاصية Texture، ونختار إنشاء واحدة جديدة New AtlasTexture. سننقر بعد ذلك على هذا الخيار لتظهر لنا نافذة أسفلها الخاصية Atlas. نسحب الآن الصورة Number_font (8 x 8).png إلى هذه الخاصية ثم نضبط قيم الخاصية Region على (32, 8, 8, 😎. بعد ذلك ننقر مجددًا على الخاصية Texture لتغلق النافذة، ثم اضبط قيمة الخاصية Stretch Mode على Keep Aspect Centered. سنعود الآن إلى نافذة المشهد ونختار العقدة Digit0، ثم نضغط على المفتاحين Ctrl+D سبع مرات لإنشاء سبع عقد أخرى مماثلة للأولى من ناحية الشكل والخاصيات. حيث ستبدو نافذة العرض بعد هذه الخطوات كما يلي: على الرغم من إنشاء ثمانية عقد TextureRect للأرقام، إلا أن قيمة الخاصية Texture تبقى نفسها، وهذه مشكلة، فعند تغيير الخاصية Region لعرض الأرقام المختلفة ستعرض كل العقد صورة الرقم نفسه. يعود السبب في ذلك إلى أن كائنات الموارد مثل العقدة Texture ستحمّل في الذاكرة مرةً واحدة ثم تتشاركها العقدة المختلفة. وهذا أمر فعّال جدًا لأنه لا يهدر الذاكرة بتحميل نفس النسخة من المورد عدة مرات. إذًا، علينا تخصيص جزء من المورد لكل عقدة حتى تكون الصورة المعروضة في كل عقدة فريدة، وهنا سننقر في كل عقدة على السهم المجاور للخاصية AtlasTexture ثم نضغط على خيار اجعله فريدًا Make Unique. نضيف الآن سكريبت إلى العقدة ScoreCounter، ونختار فيها المنطقة الصحيحة Region من الصورة لكل رقم نريد عرضه: extends HBoxContainer var digit_coords = { 1: Vector2(0, 0), 2: Vector2(8, 0), 3: Vector2(16, 0), 4: Vector2(24, 0), 5: Vector2(32, 0), 6: Vector2(0, 8), 7: Vector2(8, 8), 8: Vector2(16, 8), 9: Vector2(24, 8), 0: Vector2(32, 8) } func display_digits(n): var s = "%08d" % n for i in 8: get_child(i).texture.region = Rect2(digit_coords[int(s[i])], Vector2(8, 8)) تبدأ الشيفرة بتشكيل قائمة من الإحداثيات التي تمثل أماكن من الصورة يحدد كل منها مكان وجود رقم، ثم تنسّق الدالة ()display_digits العدد المطلوب ليكون من 8 أرقام، بحيث لو كان أمامنا الرقم 285 مثلًا، فسيكتب بالشكل 00000258. بعد ذلك، سنضع الرقم المناسب في كل منزلة من العدد السابق اعتمادًا على مصفوفة الإحداثيات. إضافة سكريبت واجهة المستخدم UI نعود الآن إلى المشهد ui ثم نضيف المشهد ScoreCounter إلى العقدة HBoxContainer. نضيف بعد ذلك السكريبت التالي إلى العقدة UI: extends MarginContainer @onready var shield_bar = $HBoxContainer/ShieldBar @onready var score_counter = $HBoxContainer/ScoreCounter func update_score(value): score_counter.display_digits(value) func update_shield(max_value, value): shield_bar.max_value = max_value shield_bar.value = value سنستدعي هاتين الدالتين في المشهد الرئيسي Main في كل مرة نحتاج فيها إلى تغيير النتيجة أو قوة درع السفينة. إضافة المشهد UI إلى المشهد الرئيسي Main سنضيف الآن عقدةً من النوع CanvasLayer إلى المشهد الرئيسي Main، ثم منسخ إليها المشهد UI كعقدة ابن. ستُنشئ العقدة CanvasLayer طبقة رسم جديدة، ولهذا ستُرسم واجهة المستخدم فوق جميع مكونات اللعبة؛ ولحل هذه المشكلة سنغيّر التابع التالي في السكريبت main.gd كما يلي: func _on_enemy_died(value): score += value $CanvasLayer/UI.update_score(score) درع اللاعب يمكننا إضافة الدرع إلى سكريبت اللاعب، عبر إضافة الأسطر التالية إلى الملف player.gd: signal died signal shield_changed @export var max_shield = 10 var shield = max_shield: set = set_shield سنستدعي الدالة ()set_shield من خلال عملية الإسناد = set في كل مرة يُضبط فيها المتغير shield الذي يمثل قيمة درع المركبة كما يلي: func set_shield(value): shield = min(max_shield, value) shield_changed.emit(max_shield, shield) if shield <= 0: hide() died.emit() نستطيع أيضًا وصل إشارة المركبة area_entered كي نلتقط اصطدام العدو بالمركبة: func _on_area_entered(area): if area.is_in_group("enemies"): area.explode() shield -= max_shield / 2 لنضف الآن بعض الضرر إلى درع المركبة عندما تُصاب في سكريبت قذائف العدو enemy_bullet.gd، وذلك على النحو الآتي: func _on_area_entered(area): if area.name == "Player": queue_free() area.shield -= 1 في الأخير، علينا وصل إشارة اللاعب shield_changed إلى الدالة التي تحدّث شريط الدرع في واجهة المستخدم، ولهذا سننتقل إلى الفاحص بعد اختيار عقدة اللاعب Player في المشهد الرئيسي. سننقر بعد ذلك في نافذة العقدة Node نقرًا مزدوجًا على الإشارة، وذلك لفتح نافذة توصل إشارة إلى دالة Connect a Signal. سنختار بعد ذلك العقدة UI ثم نكتب update_shield في صندوق الدالة المتلقية Receiver Method. يمكننا الآن تشغيل اللعبة والتأكد من أن طاقة الدرع تنخفض عندما تصيبه قذيفة أو مركبة معادية. ختامًا بهذا نكون قد تعرفنا على كيفية بناء واجهة المستخدم وعارض النتيجة في الألعاب ثنائية الأبعاد عبر محرك الألعاب جودو، وسنتعرف على كيفية تطوير اللعبة أكثر بالمقالات الموالية. ترجمة -وبتصرف- للمقال: UI and Score. اقرأ أيضًا المقال السابق: إنشاء مشهد إطلاق العدو للنشر في لعبة السفينة الفضائية إنشاء المشهد الرئيسي للعبة ثنائية الأبعاد عبر محرك Godot تصميم مشهد اللاعب في لعبة ثنائية الأبعاد باستخدام Godot تعرف على العقد Nodes في محرك ألعاب جودو Godot
-
بعد أن بنينا شخصية لاعب قادر على إطلاق النار على العدو في لعبتنا ثنائية الأبعاد ضمن محرك الألعاب جودو، لا بد من إنشاء عدو في اللعبة ونبرمج طريقة حركته في اللعبة وطريقة إضافة آلية إطلاق النار إليه ليهاجم اللاعب، وهذا ما سنتعلمه في مقالنا الذي هو جزء من سلسلة دليل جودو. بناء المشهد سنستخدم العقدة Area2D كأساس لبناء المشهد لأننا بحاجة إلى اكتشاف تداخل كائن العدو مع اللاعب أو قذائفه، كما سنحتاج إلى عدة عقد نلخصها كالتالي: Enemy: Area2D Sprite2D CollisionShape2D AnimationPlayer MoveTimer: Timer ShootTimer: Timer نختار العقدة Enemy ثم ننقر على النافذة عقدة Node إلى جوار الفاحص inspector وننقر بعدها على مجموعات Groups، ثم على أيقونة + لإضافة مجموعة جديدة سنسميها enemies. ملاحظة: من المهم هنا تذكر أن السكريبت في مشهد القذائف يبحث عن كائنات من مجموعة الأعداء enemies. سننتقل بعد ذلك إلى العقدة Sprite2D ثم نضيف الصورة Bon_Bon (16 x 16).png إلى الخاصية Texture ونضبط قيمة الخاصية Animation>Hframes على 4. ملاحظة: تمثل الصورة Bon_Bon (16 x 16).png مجموعة من الإطارات الرسومية التي تشكل الحركة فكل إطار يمثل وضعية أو جزءًا من هذه الحركة، وعند ضبط Hframes إلى 4 معناه أننا نخبر جودو بأن الصورة مقسمة أفقيًا إلى 4 إطارات، مما يسمح له بالتعامل مع كل إطار على حدة فإذا كانت الصورة بحجم 16 × 16 بكسل فعند تقسيمها إلى 4 إطارات أفقية سيكون كل إطار بعرض 4 بكسل وارتفاع 16 بكسل ويمكن عرض كل إطار بشكل منفصل لتكوين الحركة. ننقر الآن على العقدة CollisonShape2D ثم ننتقل إلى الخاصية Shape ونختار شكل التصادم RectangleShape2D ونضبط حجمه ليناسب أبعاد الأيقونة؛ ثم ننتقل إلى أيقونتي المؤقتات ونفعّل الخاصية One Shot لكل مؤقت. ننقر على العقدة AnimationPlayer وننتقل إلى لوحة التحريك Animation Panel، وننشئ رسمًا متحركًا باسم bounce ونضبطه ليكرر نفسه looping وأن يبدأ العمل تلقائيًا عند تشغيل اللعبة autoplay بالنقر على الأزرار الموافقة. بعد هذه المرحلة، سنضبط الخيار انطباق أو محاذاة Snap في أسفل اللوحة على القيمة 0.05. سننشئ الآن مسارات تحريك لتتبع الخاصيتين Textrure و Hframes للشخصية وهي بحالتنا العقدة Sprite2D، وذلك بالنقر على أيقونة المفتاح إلى جانب كل منهما. وسنحتاج إلى مفاتيح مرجعية على هذه المسارات لاحقًا عندما نضيف رسمًا متحركًا جديدًا باسم explode يستخدم قيمًا مختلفة لهاتين الخاصيتين. سنضيف الآن مسارًا تحريكيًا للخاصية Frame بالنقر بدايةً على المفتاح إلى جانب الخاصية، ونبدأ بعدها بإنشاء مفتاح مرجعي يتغير عنده إطار الشخصية وفق الترتيب التالي 2>1>0>3>0، بحيث يفصل بين كل مفتاحين 0.1 ثانية ماعدا آخر إطارين سيكونان متداخلين. لتنفيذ العملية، سنحرك المقبض الأزرق على الخط الزمني مسافة 0.1 ثانية ونضع قيمة الإطار المطلوب في مربع الخاصية Frame، ثم ننقر على زر المفتاح وهكذا. وسينتج عن تحريك الرسم بهذا الشكل أيقونة تنمو وتعود إلى حجمها الطبيعي في النهاية. وسيبدو شكل لوحة التحريك كالتالي: ننقر على زر التشغيل في لوحة التحريك لرؤية نتيجة العمل، وبإمكاننا طبعًا تعديل الحركة حسب رغبتنا. نضيف الآن رسمًا متحركًا جديدًا باسم explode ونضبط مدته على 0.4 ثانية، كما نستبدل صورة الشخصية بالصورة Explosion (16 x 16).png وننشئ مسارات للخاصيات Frame و Texture و Hframes. سنعدّل قيمة الخاصية Hframes إلى 5، ثم كما فعلنا سابقًا، ونضع مفتاحين مرجعيين في المسار الأول عند اللحظة 0، وتكون قيمة الإطار 0 والثانية عند اللحظة 0.4، كما ستكون قيمة الإطار 4. عند الانتهاء من كل ذكل، سنشغل الرسم المتحرك لرؤية النتيجة. سكريبت شخصية العدو ينتشر الأعداء أعلى شاشة اللعبة على شكل شبكة، حيث ستقترب شبكة الأعداء من اللاعب بعد فترة زمنية محددة، ثم يعود إلى الأعلى ما لم يُدمر منها، كما سيطلق الأعداء النار على اللاعب دوريًا. سنضيف سكريبتًا إلى عقدة العدو ونعرّف المتغيرات التالية: extends Area2D var start_pos = Vector2.ZERO var speed = 0 @onready var screensize = get_viewport_rect().size يتتبع المتغير start_pos موقع انطلاق الأعداء كي يعودوا إلى موقعهم الأصلي بعد الحركة. سنضبط قيمة المتغير عندما ننشر الأعداء في الشبكة، وسنستدعي الدالة ()start الخاصة بهم: func start(pos): speed = 0 position = Vector2(pos.x, -pos.y) start_pos = pos await get_tree().create_timer(randf_range(0.25, 0.55)).timeout var tween = create_tween().set_trans(Tween.TRANS_BACK) tween.tween_property(self, "position:y", start_pos.y, 1.4) await tween.finished $MoveTimer.wait_time = randf_range(5, 20) $MoveTimer.start() $ShootTimer.wait_time = randf_range(4, 20) $ShootTimer.start() سنستدعي الدالة السابقة عند نشر الأعداء، ونمرر إليها شعاع موضع يمثل مكان ظهور العدو على الشاشة، وسنلاحظ عندها أننا ننشر العدو فوق الحافة العلوية للشاشة. سنعطي y قيمة سالبة كي نتمكن من تحريك العدو عندما يدخل إلى الشاشة باستخدام مولد إطارات بينية Tween، ونضبط أيضًا قيمتي المؤقتين عشوائيًا كي لا يتحرك العدو ويطلق النار في نفس الوقت، وبعد ذلك سنصل إشارة timeout الخاصة بكل مؤقت. func _on_timer_timeout(): speed = randf_range(75, 100) func _on_shoot_timer_timeout(): $ShootTimer.wait_time = randf_range(4, 20) $ShootTimer.start() يمكن هنا التحرك عند انتهاء المؤقت من عد الفترة الزمنية التي يُضبط عليها، لكننا لم ننشئ قذائف الأعداء بعد، وهذا ما سنتعامل معه لاحقًا. وبما أننا سنغير سرعة حركة الأعداء من خلال المتغير speed، فمعنى ذلك، أن بإمكاننا التحرك عبر كتابة الشيفرة التالية في دالة المعالجة: func _process(delta): position.y += speed * delta if position.y > screensize.y + 32: start(start_pos) إن لم تكن قيمة المتغير speed هي 0، فسنرى العدو يتحرك على الشاشة، وعندما يصل إلى الأسفل يعود من الأعلى مجددًا. لقد كتبنا الدالة التي تعالج انفجار الأعداء عندما تصطدم بها قذيفة اللاعب الدالة ()explode عندما بنينا مشهد القذيفة، لهذا سنضيف نفس الدالة هنا: func explode(): speed = 0 $AnimationPlayer.play("explode") set_deferred("monitoring", false) died.emit(5) await $AnimationPlayer.animation_finished queue_free() سنوقف في هذه الدالة حركة العدو، ثم نشغّل الرسم المتحرك الخاص بعملية الانفجار ونحذف بعدها العدو في نهاية الدالة. وتضمن الدالة ()set_deferred إيقاف الخاصية monitoring في كائن العدو كي لا تصيبه قذيفة أخرى أثناء انفجاره، وعندها سيصيب القذيفة التي تنفجر ويفجرها مجددًا ويستدعي الدالة التي تنفذ عملية التفجير مجددًا، وهكذا. سنضيف الآن الإشارة died إلى أعلى السكريبت: signal died وسنستخدم هذه الإشارة لإبلاغ المشهد الرئيسي أن اللاعب حقق بعض النقاط. نشر العدو سنعود الآن إلى المشهد الرئيسي Main لإضافة بعض الأعداء إلى اللعبة، ولهذا سنضيف سكريبتًا إلى المشهد الرئيسي ونبدأ بتحميل مشهد العدو برمجيًا: extends Node2D var enemy = preload("res://enemy.tscn") var score = 0 لن يبدأ نشر العدو في اللعبة قبل النقر على زر البداية. وطالما أننا لم نرتّب هذا الأمر بعد، فسننشره مباشرة: func _ready(): spawn_enemies() func spawn_enemies(): for x in range(9): for y in range(3): var e = enemy.instantiate() var pos = Vector2(x * (16 + 8) + 24, 16 * 4 + y * 16) add_child(e) e.start(pos) e.died.connect(_on_enemy_died) تنشر الشيفرة السابقة 27 عدوًا ضمن شبكة في النصف الأعلى من الشاشة، ولا بد من التأكد من وصل الإشارة died لكل عدو، ولهذا علينا إضافة الدالة التالية: func _on_enemy_died(value): score += value سنشغل الآن المشهد، وسنرى مجموعةً من الأعداء أعلى الشاشة يسقطون دوريًا نحو الأسفل. إطلاق النار من المركبات المعادية طالما أن مركبات العدو ستتمكن من إطلاق النار، فسنمنحها شيئًا لتطلق النار عليه. مشهد قذائف العدو سننشئ مشهدًا باسم EnemyBullet مشابهًا من ناحية التكوين لمشهد قذائف اللاعب. لن نكرر خطوات الإنشاء هنا طبعًا، لهذا يمكن العودة إلى مشهد قذائف اللاعب bullet في حال مصادفة مشكلة ما؛ إذ سيكون الفرق الوحيد هو الصورة المستخدمة للقذيفة في هذا المشهد، وهي الصورة Enemy_projectile (16 x 16).png، وسيختلف السكريبت هنا قليلًا: extends Area2D @export var speed = 150 func start(pos): position = pos func _process(delta): position.y += speed * delta نصل الإشارتين screen_exited و area_entered العائدتين للعقدتين VisibleOnScreenNotifier2D و Area2D على التوالي: func _on_visible_on_screen_notifier_2d_screen_exited(): queue_free() func _on_area_entered(area): if area.name == "Player": queue_free() ملاحظة: تُطلق الإشارة screen_exited عندما يخرج الكائن من الشاشة، وتُستخدم لحذفه أو إعادة ضبطه وتطلق الإشارة area_entered عندما يدخل جسم إلى منطقة محددة وهي تُستخدم للكشف عن الاصطدامات أو التفاعلات. وكما نلاحظ، نحن هنا نلتقط اصطدام القذيفة المعادية باللاعب، لكننا لم نحصل على النتيجة المطلوبة حتى اللحظة. سنعود إلى ذلك بعد إضافة طريقة تصف تضرر سفينة اللاعب عند إصابته. إضافة آلية إطلاق النار إلى العدو سنحمّل مشهد قذيفة العدو في أعلى سكريبت العدو: var bullet_scene = preload("res://enemy_bullet.tscn") ونعدّل بعد ذلك دالة إطلاق النار إلى النحو الآتي: func _on_shoot_timer_timeout(): var b = bullet_scene.instantiate() get_tree().root.add_child(b) b.start(position) $ShootTimer.wait_time = randf_range(4, 20) $ShootTimer.start() سنشغّل الآن المشهد Main، وسنرى بعض قذائف العدو تهطل إلى الأسفل بين الفينة والأخرى. ختامًا بهذا نكون قد أتممنا إعداد مشهد العدو الذي يطلق النار في لعبتنا ثنائية الأبعاد، وسنتعرف على المزيد لتطوير لعبتنا في المقالات القادمة. ترجمة -وبتصرف- للمقالين: Enymies و Enemy Shooting. اقرأ أيضًا المقال السابق: إنشاء المشهد الرئيسي للعبة ثنائية الأبعاد عبر محرك Godot بناء مشهد القذيفة وإطلاق النار في لعبة سفينة الفضاء تصميم مشهد اللاعب في لعبة ثنائية الأبعاد باستخدام Godot برمجة عدو وحيوان أليف في لعبة Godot تعرف على العقد Nodes في محرك ألعاب جودو Godot
-
سنعمل في هذا المقال وهو جزء من سلسلة دليل جودو على إنشاء المشهد الرئيسي للعبتنا ثنائية الأبعاد، فقبل صناعة الأعداء أو تشغيل السفينة أو أي عمل آخر في اللعبة، سنحتاج إلى توفير مكان تتواجد فيه كل هذه الكائنات معًا، ويُدعى هذا المكان عادة في عالم الألعاب بمشهد المستوى Level أو المشهد الرئيسي Main Scene، وهذا الاسم هو ما سنعتمده في مقالنا. إنشاء الخلفية سنبدأ المشهد بعقدة من النوع Node2D ونسميها Main ثم نحفظ المشهد، بعدها نضيف عقدة ابن للعقدة Main من النوع Sprite2D ونسيمها Background لتمثل صورة الخلفية. نجعل بعد ذلك الصورة باسم Space_BG (2 frames) (64 x 64).png نقشًا خاصًا بها، بحيث ستساعد هذه الخلفية في تغطية كامل شاشة اللعبة عند الضبط الصحيح للإعدادات. تضم الصورة السابقة إطارين أبعاد كل منهما 64x64 ونريد أن نرصف الصورة على كامل خلفية المشهد، لهذا سنبدأ بضبط الإعدادات التالية: ننتقل إلى نافذة الفاحص Inspector بعد النقر على العقدة Background، ثم نضبط قيمة الخاصية Offset>Centerعلى القيمة off ليصبح موقع الزاوية العليا اليسارية للصورة هي مبدأ الإحداثيات وليس منتصف الشاشة نضبط قيمة الخاصية Region>Enabled على on، فتظهر مجموعة الخاصيات Rect؛ وهنا نضبط الارتفاع على 320 والعرض على 240 كي تتوسع الصورة لتغطي كامل الشاشة نضبط قيمة الخاصية Texture>Repeat على Enabled كي تكرر الصورة نفسها على كامل أبعاد نافذة اللعبة نضيف الآن مشهد اللاعب Player كمشهد فرعي إلى المشهد Main، وذلك بالنقر على زر إلحاق مشهد فرعي Instantiate Child Scene. تحريك الخلفية يمكن جعل المشهد أكثر ديناميكية عن طريق تحريك الخلفية، وذلك باستخدام الشيفرة عن طريق تغيير قيمة الخاصية region_rect عند كل إطار؛ مع ذلك سنستخدم العقدة AnimationPlayer، ولهذا نضيف واحدة إلى العقدة Main. تظهر اللوحة Animation تحت نافذة المحرر، وتعرض الكثير من المعلومات التي سنطلع على طريقة تنظيمها: سننقر الآن على زر التحريك Animation، ثم نضغط على الخيار تحريك جديد New Animation، ويمكننا تسمية الرسم المتحرك الجديد بالاسم Scroll. سنضبط بعد ذلك طول المقطع Length على 2 بتغيير قيمة مربع مدة الرسم المتحرك بالثواني ونفعّل خياري تكرار التحريك Looping و تشغيل تلقائي عند التحميل Autoplay بالنقر على زريهما. يعمل الرسم المتحرك عن طريق إضافة مسارات تمثل الخصائص التي تريد التحكم بها من خلال العقدة AnimationPlayer؛ وضمن الخط الزمني للاعب timeline، سنضيف مفاتيح مرجعية keyframes تعرّف القيم الجديدة للخاصيات في الوقت المحدد. يمكننا إضافة إطار أو مفتاح مرجعي Keyframe إلى الرسم المتحرك بالنقر على أيقونة المفتاح التي تُعرض إلى جانب كل خاصية من الخواص في نافذة الفاحص، وهنا قبل وضع الإطار المرجعي، يجب التأكد من أن المقبض الأزرق scrubber متواجد على الخط الزمني في نافذة التحريك على القيمة 0. نختار الآن العقدة Background، ثم ننتقل إلى الفاحص وننقر المفتاح إلى جانب الخاصية Region>Rect، وستظهر عندها رسالة تأكيد تخبرنا بأننا سننشئ مقطعًا جديدًا لخاصية وإضافة مفتاح لها، وسنرى عند تأكيد الرسالة مسارًا جديدًا أضيف إلى لوحة التحريك يحتوي على نقطة صغيرة تمثل المفتاح المرجعي الذي أضفناه. عند هذه المرحلة، سيكفي سحب المقبض إلى الزمن 2 ثم تغيير قيمة y للخاصية Region>Rect إلى 64. وعند النقر على زر تشغيل الرسم Play في لوحة التحريك، سنلاحظ كيف تتحرك الخلفية ببطئ خلف اللاعب. الخاتمة شرحنا في هذا المقال كيفية إعداد وتجهبز المشهد الرئيسي للعبتنا ثنائية الأبعاد وأضفنا الخلفية للمشهد وضبطناها بالطريقة المناسبة. الخطوة التالية التي علينا القيام بها هي إضافة كائنات الأعداء وإعداد طريقة تحركهم ضمن اللعبة، وهذا ما سنفعله في المقال التالي؛ إذ سننشئ مشهد عدو وحيد كما فعلنا مع القذائف في مقال سابق ونكررها في اللعبة عند الحاجة. ترجمة -وبتصرف- للمقال: Main Scene. اقرأ أيضًا المقال السابق: بناء مشهد القذيفة وإطلاق النار في لعبة سفينة الفضاء تصميم مشهد اللاعب في لعبة ثنائية الأبعاد باستخدام Godot برمجة عدو وحيوان أليف في لعبة Godot
-
انتهينا في المقال السابق من تحريك سفينة الفضاء، وسنتابع في هذا المقال الذي هو جزء من سلسلة دليل جودو بناء مشهد القذيفة أو الرصاصة وعملية إطلاق النار من قبل سفينة الفضاء التي تمثل اللاعب الأساسي في لعبتنا ثنائية الأبعاد، وسننفذها بشكل كائن قابل لإعادة الاستخدام. الكائنات القابلة لإعادة الاستخدام سيطلق اللاعب كمًا كبيرًا من القذائف مع تقدم اللعبة وجميعها أشياء متطابقة تمامًا، وبالتالي سنتحتاج إلى تنفيذ ما يلي: إظهار القذيفة أمام اللاعب تحريك القذيفة نحو الأمام حتى تغادر الشاشة التقاط حدث تصادم القذيفة مع الأعداء وطالما أن كل القذائف لها نفس الوظيفة، فسنوفر الكثير من العمل بتصميم نموذج أولي للطلقة واستخدامه كنقطة انطلاق في إنشاء العدد الذي نريده من النسخ المتطابقة، ونظام المشاهد في جودو مثالي لهذا الغرض. مشهد القذيفة سننشئ هنا مشهدًا جديدًا من خلال النقر على خيار المشهد Scene ثم على خيار مشهد جديد New Scene، أو بالنقر على الزر + في أعلى نافذة العرض. وكما فعلنا في مشهد اللاعب، لا بد من تحديد العّقد التي نحتاجها حتى تعمل القذيفة كما هو مطلوب. بإمكاننا استخدام العقدة مجددًا طالما أنها قادرة على التقاط حدث الاصطدام بجسم، وهذا يعني بدوره أننا سنحتاج إلى شكل تصادم وشخصية لعرض صورة القذيفة، كما سنحتاج إلى طريقة لاكتشاف خروج القذيفة خارج الشاشة حتى نزيلها تلقائيًا. وستكون العقد على النحو الآتي: - Area2D: Bullet - Sprite2D - CollisionShape2D - VisibleOnScreenNotifier2D سننقر على العقدة Sprite2D ثم نسحب الصورة Player_charged_beam (16 x 16).png من مدير الملفات إلى الخاصية Texture في نافذة الفاحص Inspector. وكما نلاحظ، توجد أكثر من نسخة للقذيفة، ولهذا سنضبط قيمة الخاصية Animation> Hframe على القيمة 2 كي نرى نسخةً واحدةً فقط في كل مرة. سنضبط الآن شكل التصادم CollisionShape2D كما فعلنا في المقال السابق مع شكل التصادم الخاص بعقدة اللاعب. سكريبت القذيفة سنلحق الآن سكريبتًا جديدًا بالعقدة Bullet ونبدأ بتحريك القذيفة كالآتي: extends Area2D @export var speed = -250 func start(pos): position = pos func _process(delta): position.y += speed * delta من المفترض أن يكون الأمر مألوفًا، فهو مشابه لسكريبت اللاعب، وقد غيرنا فقط position.y لأن الحركة ستكون شاقوليةً نحو الأعلى. يسمح لنا التابع ()start الذي عرّفناه بضبط موقع البداية position للقذيفة، لأن اللاعب سيتحرك باستمرار وينشر القذائف باتجاهات مختلفة. ربط الإشارات Signals نختار العقدة Bullet وننقر على النافذة الفرعية عقدة Node إلى جوار الفاحص: تعرض لقطة الشاشة السابقة جميع الإشارات Signals التي يمكن للعقدة المختارة أن تبثها. تخبرنا الإشارات في جودو أن شيئٍا ما قد حدث، وفي حالتنا سنستخدم الإشارة area_entered لتخبرنا متى تلامس فيها هذه القذيفة عقدةً أخرى من النوع Area2D. ولفعل ذلك، سنختار الإشارة area_entered، ثم ننقر على الزر توصيل Connect أسفل النافذة، بعدها ننقر على الزر وصل Connect في النافذة التي تظهر؛ إذ لسنا بحاجة إلى تغيير أي شيء فيها. سنلاحظ الآن أننا قد عدنا إلى محرر السكريبت، كما سنلاحظ وجود دالة جديدة في السكريبت bullet.gd. وإلى جانب الدالة، سنجد أيقونة اتصال خضراء لتدلنا على أن الإشارة متصلة بها. وستُستدعى هذه الدالة في كل مرة تلامس فيها هذه العقدة شيئًا ما. لنضف الآن هذه الشيفرة: func _on_area_entered(area): if area.is_in_group("enemies"): area.explode() queue_free() سنتحقق في هذه الشيفرة مما إذا كانت القذيفة قد أصابت عدوًا، بحيث إن فعلت ذلك، نطلب من العدو أن ينفجر، ومن ثم نمسح القذيفة مباشرةً. نكرر نفس الخطوات السابقة لوصل الإشارة screen_exited العائدة إلى العقدة VisibleOnScreenNotifier2D ونضيف الشيفرة التالية في الدالة التي توّلدها الإشارة: func _on_visible_on_screen_notifier_2d_screen_exited(): queue_free() إطلاق النار يقدم المشهد Bullet كائنًا قابلًا لإعادة الاستخدام يمكن نسخه في كل مرة يطلق فيها اللاعب النار. ولضبط عملية إطلاق النار، سنتبع ما يلي. إضافات إلى مشهد اللاعب لنعد إلى سكريبت اللاعب Player ونضيف بعض المتغيرات الجديدة: @export var cooldown = 0.25 @export var bullet_scene : PackedScene var can_shoot = true يساعدنا التوجيه export@ هنا في ضبط قيم المتغيرات ضمن نافذة الفاحص، وبالتالي سنكون قادرين على ضبط وقت الانتظار قبل إعادة إطلاق النار Cooldown Time. نعود الآن إلى مشهد اللاعب، ثم نغيّر قيمة المتغير bullet_scene بالنقر على الخاصية الموافقة في الفاحص، ثم نختار الملف bullet.tscn، أو بسحب المشهد من نافذة نظام الملفات وسحبه إلى الخاصية. يطلق المبرمجون على المتغير can_shoot تسمية الراية Flag، وهو متغير بولياني منطقي يتحكم بشرط معين. وفي حالتنا سيتحكم بإمكانية إطلاق اللاعب للنار أو لا، وستكون قيمة هذا المتغير في فترة الانتظار false. سنضيف تاليًا دالة ()start مشابهة للتي أضفناها إلى مشهد القذيفة، والتي تساعدنا على ضبط بعض القيم الابتدائية للاعب وإعادة ضبطها عندما تبدأ اللعبة من جديد. func _ready(): start() func start(): position = Vector2(screensize.x / 2, screensize.y - 64) $GunCooldown.wait_time = cooldown تضع الشيفرة السابقة اللاعب في أسفل ومنتصف الشاشة وهو مكان جيد للانطلاق، كما نضمن أن زمن الانتظار يأخذ القيمة المناسبة. تُستدعى الدالة ()shoot في أي وقت نضغط فيه على مفتاح الإطلاق الذي يتفعّل عن ضغط الزر Space: func shoot(): if not can_shoot: return can_shoot = false $GunCooldown.start() var b = bullet_scene.instantiate() get_tree().root.add_child(b) b.start(position + Vector2(0, -8)) تتحقق الدالة بدايةً مما إذا ما كان إطلاق النار مسموحًا، فإن لم يكن كذلك، فسيُنهى تنفيذ الدالة مباشرةً باستخدام الأمر return. وإن كان مسموحًا للاعب بإطلاق النار، فسنضبط قيمة الراية على false ونبدأ العد لفترة الانتظار. سننشئ بعذ ذلك نسخةً عن مشهد القذيفة ونضيفه إلى اللعبة، ونستدعي الدالة ()start الخاصة بالقذيفة للتأكد من وضعها في المكان المناسب فوق سفينة اللاعب. يمكن استدعاء هذه الدالة عندما يضغط اللاعب على مفتاح الإطلاق، لهذا، نضيف ما يلي داخل الدالة ()process_ بعد السطر ()position.clamp: if Input.is_action_pressed("shoot"): shoot() نصل أيضًا الإشارة timeout العائدة للعقدة GunCooldown: func _on_gun_cooldown_timeout(): can_shoot = true عندما تنتهي فترة الانتظار، يُسمح للاعب بإطلاق النار مجددًا؛ وهنا سنشغّل المشهد ونجرب التحرك وإطلاق النار. وكما هو ظاهر في الصورة أعلاه، فقد أضفنا قذائف جديدة كأبناء لشجرة المشاهد get_tree().root وليس إلى مشهد اللاعب، لأننا لو فعلنا ذلك فستكون القذائف أبناءً للسفينة وتتحرك مع حركتها. الخاتمة وهكذا نكون قد أنجزنا مشهد إطلاق النار كاملة في لعبتنا ثنائية الأبعاد، بدءًا من تصميم القذيفة وربط إشارات التصادم، وصولًا إلى تنفيذ آلية الإطلاق والتحكم بفترة التهدئة بين كل طلقة وأخرى، وتعلمنا كيفية استخدام نظام المشاهد في جودو لننشئ كائنات قابلة لإعادة الاستخدام، وكيفية بناء تفاعل ديناميكي بين اللاعب وبيئة اللعب. ترجمة-وبتصرف- للمقالين: Bullet Scene و Shooting. اقرأ أيضًا المقال السابق: كتابة شيفرة التحكم بسفينة الفضاء عبر محرك Godot تصميم مشهد اللاعب في لعبة ثنائية الأبعاد باستخدام Godot برمجة صاروخ موجه يتبع هدف متحرك عبر محرك الألعاب Godot
-
أعددنا في مقال سابق بيئة اللعبة ثم بنينا في المقال الذي يليه مشهد اللاعب، وسنتابع في هذا المقال الذي هو جزء من سلسلة دليل جودو العمل على لعبتنا ثنائية الأبعاد في محرك الألعاب جودو ونبدأ بكتابة شيفرة سفينة الفضاء التي يتحكم بها اللاعب. إضافة سكريبت إلى اللاعب يُبنى سلوك الكائنات وآليات اللعب عن طريق كتابة سكريبتات برمجية وإلحاقها بالعقد وبغيرها من الكائنات. وقد رأينا سابقًا كيف يعرض المشهد Player سفينة الفضاء ويُعرّف صندوق التصادم الذي يحيط بها وغيره من الخواص، لكنها لا تستطيع التحرك بعد، ولن يحدث شيء إذا اصطدم بها جسم. لهذا سنكتب شيفرة إضافة هذه الوظائف إلى السفينة. لفعل ذلك، سنختار العقدة Player ثم ننقر على خيار إلحاق نص برمجي Attach script. ليس علينا تغيير أي خيارات في نافذة إلحاق نص برمجي للعقدة، بل علينا النقر فقط على زر Attach Node Script، وسينتقل بنا المحرّك إلى محرر السكريبت. لنلق نظرةً إلى السطر الأول من السكريبت الذي أُضيف تلقائيًا: extends Area2D يعرّف هذا السطر نوع الكائن الذي يرتبط به السكريبت، وبالتالي سيكون السكريبت قادرًا على الوصول إلى جميع الوظائف التي تقدمها العقدة Area2D، ويجب أن يتطابق السطر دائمًا مع نوع العقدة التي تُلحق السكريبت بها. الوصول إلى السكريبتات لا يفعّل السكريبت الكثير بمفرده، بل يكتفي بتعريف وظائف إضافية في أي كائن ترتبط به فقط؛ إذ لن نحتاج أبدًا إلى الوصول إلى متغيرات في بعض السكريبتات، بل الوصول إلى خاصيات الكائن التي يُعرّفها السكريبت، وتمييز هذا الأمر مهم جدًا. تحريك السفينة سنبدأ الآن بتحريك السفينة حول الشاشة، وسنكتب شيفرة تنفّذ ما يلي: التقاط مفاتيح الإدخال التي يضغط عليها اللاعب تحريك السفينة في الاتجاه الذي يفرضه مفتاح الدخل @export var speed = 150 func _process(delta): var input = Input.get_vector("left", "right", "up", "down") position += input * speed * delta يمكّن التوجيه export@ المضاف قبل اسم المتغير في الشيفرة أعلاه من تعديل قيمته في نافذة الفاحص Inspector كما هو موضح بالصورة الآتية: أما باقي محتوى الشيفرة أعلاه، فيعني: تُستدعى الدالة ()process_ مرةً واحدة من قبل المحرّك عند تنفيذ كل إطار، وتُنفّذ الشيفرة التي تضمها يتحقق التابع ()Input.get_vector من حالة المفاتيح المضغوطة من بين المفاتيح الأربعة المخصصة للإدخال ويولّد شعاع مدخلات له نفس اتجاه الحركة نغيّر أخيرًا موقع السفينة position بإضافة شعاع المدخلات وتعديل قيمته إلى السرعة المطلوبة ثم نضربه بالمعامل delta سنشغّل المشهد الآن بالنقر على زر Run Current Scene ونحاول تحريك السفينة. البقاء ضمن الشاشة حتى الآن، إذا حاولنا الاستمرار في التحرك باتجاه محدد، فستغادر السفية شاشة اللعبة، ولهذا علينا تحديد قيمة الخاصية position كي تبقى السفينة داخل مربع الشاشة. ولحل هذه المشكلة سنضيف السطر التالي في أعلى السكريبت. @onready var screensize = get_viewport_rect().size يخبر التوجيه onready@ جودو ألا يضبط قيمة المتغيّر screensize حتى تدخل العقدة Player شجرة المشهد. ويعني ذلك حرفيّا ضرورة الانتظار حتى بداية اللعبة، لعدم وجود نافذة نتحرى أبعادها قبل أم تبدأ اللعبة. ستكون الخطوة التالية هي حصر موقع السفينة ضمن حدود المربع screensize باستخدام التابع ()clamp الذي يضمه الشعاع position كونه من النوع Vector2. func _process(delta): var input = Input.get_vector("left", "right", "up", "down") position += input * speed * delta position = position.clamp(Vector2.ZERO, screensize) بعدها نشغّل المشهد مجددًا ونحاول أن تحرك السفينة. سنلاحظ كيف تتوقف السفينة عند حواف الشاشة لكن نصفها يغادر الشاشة، وذلك لأن موقع السفينة position هو في مركزها أي مركز العقدة Sprite2D. وطالما أن أبعاد السفينة هي 16x16، فبإمكاننا تغيير مقدار الاقتصاص وزيادة ما مقداره 8 بكسل كالتالي: position = position.clamp(Vector2(8, 8), screensize - Vector2(8, 8)) ربط الرسم المتحرك بالاتجاه تتحرك السفية الآن كما هو مطلوب، وبإمكاننا اختيار صور مائلة للسفينة عند التحرك يمينًا أو يسارًا وكذلك صور مائلة للهب كي يعطي الحركة ديناميكية أكبر. وللتحقق من جهة الحركة، نستطيع التحقق من قيمة x لشعاع الإدخال input فيما لو كان موجبًا فيعطي حركة يمينًا، أو سالبًا فيعطي حركة يسارًا، أو صفر بمعنى عدم وجود أي حركة. والآن، سنطبق التغييرات على الخاصية frame للعقدة Sprite2D، وذلك باختيار إطار معين عند الحركة يمينًا أو يسارًا لتغيير شكل صورة السفينة؛ وعلى الخاصية animation للعقدة AnimatedSprite2D لتغيير شكل اللهب كما يلي: func _process(delta): var input = Input.get_vector("left", "right", "up", "down") if input.x > 0: $Ship.frame = 2 $Ship/Boosters.animation = "right" elif input.x < 0: $Ship.frame = 0 $Ship/Boosters.animation = "left" else: $Ship.frame = 1 $Ship/Boosters.animation = "forward" position += input * speed * delta position = position.clamp(Vector2(8, 8), screensize-Vector2(8, 8)) ختامًا بهذا نكون قد وصلنا إلى نهاية المقال الذي تعرفنا فيه على كيفية كتابة شيفرة التحكم بسفينة الفضاء عبر محرك Godot. يجب التأكد من أن كل شيء يعمل بشكل طبيعي كما هو مطلوب قبل الانتقال إلى الخطوة التالية التي سنشرحها في المقال التالي، والذي سننشئ فيها مشهد الرصاصة ونبرمج عملية إطلاق النار. ترجمة -وبتصرف- لمقال Coding the Player. اقرأ أيضًا المقال السابق: تصميم مشهد اللاعب في لعبة ثنائية الأبعاد باستخدام Godot إنشاء لعبة سفينة فضاء ثنائية الأبعاد في جودو تحريك سفينة فضاء باستخدام RigidBody2D في جودو تعرف على العقد Nodes في محرك ألعاب جودو Godot
-
سنوضّح في هذا المقال من سلسلة دليل جودو كيفية برمجة صاروخ موجه ، والذي هو مقذوف يبحث عن هدف متحرك، ، حيث سنستخدم عقدة Area2D لتنفيذ حركة الصاروخ، مع إضافة التأثيرات البصرية مثل الدخان والانفجارات' كما سنوضح كيفية استخدام التسارع والتوجيه الذكي Steering لتحريك الصاروخ نحو الهدف مع التحكم في قوة التوجيه لتحقيق حركة أكثر واقعية؛ كما سنوضح أيضًا كيفية استدعاء الدوال المناسبة للتحكم في وقت حياة الصاروخ، وكيفية تفاعله مع البيئة المحيطة. ستساعدنا هذه المفاهيم الأساسية على تطوير أنظمة موجهة أخرى في الألعاب التي تتطلب تفاعلًا ديناميكيًا مع الأهداف المتحركة. برمجة صاروخ موجه للكشف عن الهدف المتحرك لبرمجة صاروخ موجه، والذي هو مقذوف يبحث عن هدف متحرك، سنستخدم عقدة Area2D للمقذوف؛ إذ تُعَد عقد المناطق Areas خيارات جيدة للرصاصات أو الأجسام المتحركة التي تُطلق من الصاروخ لأننا بحاجة إلى كشفها عند ملامستها لشيء ما، ولكن إذا كنا بحاجة أيضًا إلى رصاصة ترجع أو ترتد، فقد تكون العقد من نوع PhysicsBody خيارًا أفضل. يتشابه إعداد العقدة وسلوك الصاروخ مع الإعداد الذي نستخدمه مع الرصاصات العادية، لذا في حال كنا قد أنشأنا مسبقًا عدة أنواع من الرصاص، فيمكننا استخدام الوراثة لإعداد جميع مقذوفاتنا استنادًا على الإعداد الأساسي نفسه. فيما يلي العقد التي سنستخدمها: Area2D: Missile Sprite2D CollisionShape2D Timer: Lifetime يمكن استخدام أي صورة نريدها بالنسبة للخامة Texture كما في المثال التالي: يمكن الآن إعداد العقد وضبط خامة الشخصية الرسومية Sprite وشكل التصادم، مع التأكّد من تدوير عقدة Sprite2D بمقدار 90 درجة، بحيث تشير إلى اليمين، مع التأكد من أنها تتطابق مع الاتجاه الأمامي للعقدة الأب. سنضيف سكربتًا ونتصل بالإشارة body_entered الخاصة بالعقدة Area2D والإشارة timeout الخاصة بالعقدة Timer كما يلي: extends Area2D export var speed = 350 var velocity = Vector2.ZERO var acceleration = Vector2.ZERO func start(_transform): global_transform = _transform velocity = transform.x * speed func _physics_process(delta): velocity += acceleration * delta velocity = velocity.clamped(speed) rotation = velocity.angle() position += velocity * delta func _on_Missile_body_entered(body): queue_free() func _on_Lifetime_timeout(): queue_free() سيؤدي هذا إلى إنشاء صاروخ يتحرك في خط مستقيم عند إطلاقه، ويمكن استخدام هذا المقذوف من خلال إنشاء نسخة منه واستدعاء التابع start() الخاص به مع التحويل Transform2D المطلوب لضبط موضعه واتجاهه. سنستخدم التسارع acceleration لتغيير السلوك للبحث عن الهدف، ولكن لا نريد أن يدور الصاروخ بسرعة كبيرة، لذا سنضيف متغيرًا للتحكم في قوة التوجيه Steering، مما يعطي الصاروخ نصفَ قطر دوران يمكن تعديله مع سلوك مختلف. سنحتاج أيضًا إلى متغير الهدف target حتى يعرف الصاروخ ما الذي يطارده، وسنضعه في التابع start() كما يلي: export var steer_force = 50.0 var target = null func start(_transform, _target): target = _target … يمكن تغيير اتجاه الصاروخ للتحرك نحو الهدف باستخدام التسارع في ذلك الاتجاه، فالتسارع هو تغير في السرعة؛ حيث يريد الصاروخ الحالي التحرك نحو الهدف مباشرةً، ولكن تشير سرعته الحالية إلى اتجاه مختلف، ويمكننا إيجاد هذا الفرق باستخدام الرياضيات الشعاعية كما يلي: يمثل السهم الأخضر التغير المطلوب في السرعة، أي التسارع acceleration، ولكن إذا انعطفنا مباشرةً، فسيبدو الأمر غير طبيعي، لذا يجب أن يكون طول متجه التوجيه محدودًا، وهذا سبب استخدام المتغير steer_force. تحسب الدالة التالية هذا التسارع، ويمكننا ملاحظة أنه لن يكون هناك توجيه عند عدم وجود هدف، لذا سيواصل الصاروخ التحرك في خط مستقيم. func seek(): var steer = Vector2.ZERO if target: var desired = (target.position - position).normalized() * speed steer = (desired - velocity).normalized() * steer_force return steer أخيرًا، يجب تطبيق قوة التوجيه الناتجة في الدالة _physics_process() كما يلي: func _physics_process(delta): acceleration += seek() velocity += acceleration * delta velocity = velocity.clamped(speed) rotation = velocity.angle() position += velocity * delta فيما يلي مثال عن النتائج مع بعض التأثيرات البصرية الإضافية مثل دخان الجسيمات والانفجارات: 03_homing_missiles.webm الكود الكامل للصاروخ الموجه مع التأثيرات البصرية فيما يلي السكربت الكامل الذي يضيف سلوك الصاروخ الموجه باستخدام Area2D للكشف عن الهدف، ويشمل أيضًا التأثيرات البصرية، مثل الانفجارات ودخان الجسيمات عند الاصطدام أو انتهاء الوقت. extends Area2D export var speed = 350 export var steer_force = 50.0 var velocity = Vector2.ZERO var acceleration = Vector2.ZERO var target = null func start(_transform, _target): global_transform = _transform rotation += rand_range(-0.09, 0.09) velocity = transform.x * speed target = _target func seek(): var steer = Vector2.ZERO if target: var desired = (target.position - position).normalized() * speed steer = (desired - velocity).normalized() * steer_force return steer func _physics_process(delta): acceleration += seek() velocity += acceleration * delta velocity = velocity.clamped(speed) rotation = velocity.angle() position += velocity * delta func _on_Missile_body_entered(body): explode() func _on_Lifetime_timeout(): explode() func explode(): $Particles2D.emitting = false set_physics_process(false) $AnimationPlayer.play("explode") await $AnimationPlayer.animation_finished queue_free() الخاتمة بهذا نكون قد وصلنا لنهاية مقالنا الذي تعلمنا كيفية برمجة صاروخ موجه في محرك الألعاب جودو Godot مع تطبيق التسارع والتوجيه الذكي لجعل الصاروخ يتبع هدفًا متحركًا' كما استعرضنا كيفية إضافة تأثيرات بصرية مثل الانفجارات والدخان عند الاصطدام أو انتهاء الوقت. يمكن تجربة تطبيق هذه الأساليب لتطوير أي ألعاب فيها أعداء يتبعون اللاعب، إذ يتغير سلوك الأعداء بناءً على موقع اللاعب وحركته. ترجمة -وبتصرّف- للقسم Homing missile من توثيقات Kidscancode. اقرأ أيضًا المقال السابق: برمجة عدو وحيوان أليف في لعبة Godot إعداد كاميرا ديناميكية وإضافة تصادمات مع خط 2D في Godot تعرف على العقد Nodes في محرك ألعاب جودو Godot فهم RayCast2D واستخداماتها في محرك ألعاب جودو
-
سنوضّح في هذا المقال من سلسلة دليل جودو كيفية برمجة عدو لمطاردة اللاعب، وكيفية برمجة كائن في اللعبة مثل حيوان أليف ليتبع شخصية اللاعب. كيفية برمجة عدو لمطاردة اللاعب تتمثّل الخطوة الأولى لجعل العدو يطارد اللاعب في تحديد الاتجاه الذي يجب أن يتحرك به العدو، حيث يمكن الحصول على متجه يؤشّر من A إلى B من خلال عملية الطرح B - A، ثم نوحّد Normalize النتيجة ونحصل على متجه الاتجاه. سنحتاج هنا إلى ضبط سرعة العدو في كل إطار للتأشير إلى اتجاه اللاعب كما يلي: velocity = (player.position - position).normalized() * speed يحتوي كائن Vector2 الخاص بمحرّك الألعاب جودو Godot على دالة مساعدة مُضمَّنة لهذا الغرض وهي: velocity = position.direction_to(player.position) * speed سيؤدي ذلك إلى السماح للعدو بمطاردة اللاعب من أيّ مسافة حتى وإن كانت بعيدة، وهذه مشكلة إلى حد ما ستحتاج إلى حلها من خلال إضافة العقدة Area2D إلى العدو ومطاردة اللاعب عندما يكون ضمن نطاق الكشف Detect Radius فقط. سنربط الآن إشارات body_entered و body_exited من العقدة Area2D حتى يعرف العدو ما إذا كان اللاعب ضمن المجال أم لا: extends CharacterBody2D var run_speed = 25 var player = null func _physics_process(delta): velocity = Vector2.ZERO if player: velocity = position.direction_to(player.position) * run_speed move_and_slide() func _on_DetectRadius_body_entered(body): player = body func _on_DetectRadius_body_exited(body): player = null ملاحظة: تفترض الشيفرة البرمجية السابقة أن اللاعب هو الجسم الوحيد الذي يدخل أو يخرج، والذي يحدث عادةً من خلال ضبط طبقات أو أقنعة التصادم المناسبة. 02_chase_02.webm يمكن توسيع هذا المفهوم ليشمل أنواعًا أخرى من الألعاب، فالفكرة الأساسية هي العثور على متجه الاتجاه من العدو إلى اللاعب، حيث إذا كانت لعبتنا مثلًا هي لعبة ذات عرض جانبي أو لها قيود أخرى في الحركة، فيمكننا استخدام المكوِّن x فقط من المتجه الناتج لتحديد الحركة. القيود يمكن ملاحظة أن هذه الطريقة تعطي حركةً خطيةً مستقيمةً بسيطة، إذ لن يتحرك العدو حول العوائق مثل الجدران، كما لن يتوقف إذا اقترب من اللاعب كثيرًا؛ ويعتمد ما يجب فعله هنا عندما يقترب العدو من اللاعب على طبيعة لعبتنا، إذ يمكن إضافة منطقة ثانية أصغر تتسبب في توقف العدو ومهاجمته، أو يمكن جعل اللاعب يتراجع عند التلامس. توجد مشكلة أخرى مع الأعداء سريعي الحركة، إذ سيغير الأعداء الذين يستخدمون هذه التقنية اتجاههم مباشرةً مع تحرّك اللاعب، ويمكن الحصول على حركة طبيعية أكثر من خلال استخدام سلوك التوجيه Steering. برمجة كائن في اللعبة مثل حيوان أليف ليتبع شخصية اللاعب لنفترض أن لدينا كيان في لعبتنا مثل حيوان أليف أو تابع لمتابعة شخصية اللاعب كما يلي: 03_pet_follow.webm سنبدأ أولًا بإضافة عقدة Marker2D إلى الشخصية، والتي تمثل المكان الذي يريد الحيوان الأليف المشي فيه بالقرب من الشخصية. جعلنا عقدة Marker2D في مثالنا ابنًا للعقدة Sprite2D، لأن الشيفرة البرمجية للشخصية تستخدم الخاصية $Sprite2D.scale.x = -1 لقلب الاتجاه الأفقي عندما تتحرك الشخصية إلى اليسار، وبالتالي ستنقلب عقدة Marker2D أيضًا لأنها ابن للعقدة Sprite2D. سكربت الحيوان الأليف فيما يلي سكربت الحيوان الأليف: extends CharacterBody2D @export var parent : CharacterBody2D var speed = 25 @onready var follow_point = parent.get_node("Sprite2D/FollowPoint") يحتوي المتغير parent على مرجع للشخصية التي يجب أن يتبعها الحيوان الأليف، ونحصل بعد ذلك على عقدة FollowPoint منه لنتمكّن من الحصول على موضعه في الدالة _physics_process(): func _physics_process(delta): var target = follow_point.global_position velocity = Vector2.ZERO if position.distance_to(target) > 5: velocity = position.direction_to(target) * speed if velocity.x != 0: $Sprite2D.scale.x = sign(velocity.x) if velocity.length() > 0: $AnimationPlayer.play("run") else: $AnimationPlayer.play("idle") move_and_slide() إذا كان الموضع قريبًا من نقطة الهدف، فسنوقف حركة الحيوان الأليف. التنقل عبر المعيقات يجب الانتباه إلى أننا قد نجد الحيوان الأليف عالقًا بين المعيقات الموجودة بعالمنا الخاص باللعبة، لذا يمكن استخدام التنقل لتكون متابعة الحيوان الأليف للشخصية أقوى. ملاحظة: يمكن تنزيل شيفرة المشروع البرمجية من Github. ختامًا بهذا نكون قد تعرفنا على كيفية برمجة شخصية عدو قادرة على مطاردة اللاعب، إلى جانب برمجة حيوان أليف قادر تتبع اللاعب داخل اللعبة، عبر محرك جودو Godot. ترجمة -وبتصرّف- للقسمين Chasing the player و Pet Following من توثيقات Kidscancode. اقرأ أيضًا المقال السابق: إعداد كاميرا ديناميكية وإضافة تصادمات مع خط 2D في Godot تعرف على العقد Nodes في محرك ألعاب جودو Godot التفاعل بين الشخصيات والأجسام الصلبة في جودو فهم RayCast2D واستخداماتها في محرك ألعاب جودو
-
سنوضّح في هذا المقال وهو جزء من سلسلة دليل جودو كيفية إعداد كاميرا ديناميكية تتحرّك وتكبّر وتصغّر المشهد لإبقاء عدة عناصر على الشاشة في الوقت نفسه، وسنتعرّف على كيفية إضافة تصادمات مع خط مرسوم ثنائي الأبعاد. طريقة إعداد كاميرا ديناميكية تتبع عدة أهداف في وقت واحد سنوضّح فيما يلي كيفية إعداد كاميرا ديناميكية لإبقاء عدة عناصر على الشاشة في الوقت نفسه. لنفترض أن لدينا لعبة تحتوي على لاعبَين ويجب إبقاء اللاعبين على الشاشة أثناء تحرّكهما، سواءً عند التباعد عن بعضهما البعض أو عند وجودهما قرب بعضهما البعض كما في المثال التالي: قد نكون معتادين على إرفاق الكاميرا باللاعب في لعبة تحتوي على لاعب واحد، بحيث تتبع الكاميرا هذا اللاعب تلقائيًا، ولكننا لا نجد ذلك مطبقًا عند وجود لاعبَين أو أكثر، أو عناصر أخرى في اللعبة نريد إبقاءها على الشاشة طوال الوقت. لحل المشكلة، يمكننا تطبيق ما يلي على الكاميرا: إضافة أو إزالة أيّ عددٍ من الأهداف إبقاء موضع الكاميرا متمركزًا عند نقطة المنتصف للأهداف ضبط تكبير أو تصغير الكاميرا لإبقاء جميع الأهداف على الشاشة سننشئ مشهدًا جديدًا باستخدام عقدة Camera2D ونرفق بها سكربتًا، وسنضيف هذه الكاميرا إلى لعبتنا بعد الانتهاء. سيبدأ السكربت بالتعليمات التالية: extends Camera2D @export var move_speed = 30 # سرعة الاستيفاء الخطي lerp لموضع الكاميرا @export var zoom_speed = 3.0 # سرعة الاستيفاء الخطي lerp لتكبير وتصغير Zoom الكاميرا @export var min_zoom = 5.0 # لن تقترب الكاميرا أكثر من هذه القيمة @export var max_zoom = 0.5 # لن تبتعد الكاميرا أكثر من هذه القيمة @export var margin = Vector2(400, 200) # تضمين بعض المساحة العازلة حول الأهداف var targets = [] # مصفوفة الأهداف التي يجب تعقّبها @onready var screen_size = get_viewport_rect().size تمكّن هذه الإعدادات من ضبط سلوك الكاميرا، إذ سنستخدم الدالة lerp() التي تمثّل الاستيفاء الخطي لجميع تغييرات الكاميرا، ولهذا سيؤدي ضبط سرعات الحركة/التكبير والتصغير على قيم أقل إلى بعض التأخير في تتبّع الكاميرا للتغييرات المفاجئة. تعتمد قيم التكبير أو التصغير العليا والدنيا أيضًا على حجم الكائنات في لعبتنا ومدى القرب أو البعد الذي نريده، لذا سنضبط هذه القيم لتناسب احتياجاتنا. تضيف الخاصية margin مساحةً إضافيةً حول الأهداف، بحيث لا تكون على حافة المنطقة القابلة للعرض بالضبط. ستكون لدينا مصفوفة أهداف، ونحصل على حجم نافذة العرض لنتمكّن من حساب المقياس الصحيح. func add_target(t): if not t in targets: targets.append(t) func remove_target(t): if t in targets: targets.erase(t) توجد دالتان مساعدتان لإضافة وإزالة الأهداف، ويمكنك استخدامهما أثناء اللعب لتغيير الأهداف التي يجب تعقّبها مثل دخول اللاعب 3 إل اللعبة. وكما نلاحظ، لا نريد تعقّب الهدف نفسه مرتين، لذا سنرفضه إذا كان موجودًا مسبقًا. تحدث معظم الوظائف في الدالة _process()، ولكن لنبدأ أولًا بتحريك الكاميرا كما يلي: func _process(delta): if !targets: return # إبقاء الكاميرا متمركزة بين الأهداف var p = Vector2.ZERO for target in targets: p += target.position p /= targets.size() position = lerp(position, p, move_speed * delta) سنكرّر ضمن الحلقة التأكيد على مواضع الأهداف ونعثر على المركز المشترك، ونتأكّد باستخدام الدالة lerp() من التحرّك بسلاسة. بعد ذلك، لا بد لنا من التعامل مع التكبير أو التصغير كما يلي: # العثور على التكبير أو التصغير الذي سيحتوي على جميع الأهداف var r = Rect2(position, Vector2.ONE) for target in targets: r = r.expand(target.position) r = r.grow_individual(margin.x, margin.y, margin.x, margin.y) var z if r.size.x > r.size.y * screen_size.aspect(): z = 1 / clamp(r.size.x / screen_size.x, min_zoom, max_zoom) else: z = 1 / clamp(r.size.y / screen_size.y, min_zoom, max_zoom) zoom = lerp(zoom, Vector2.ONE * z, zoom_speed) تأتي الوظيفة الأساسية من الصنف Rect2، إذ نريد العثور على مستطيل يحيط بكل الأهداف، والذي يمكننا الحصول عليه باستخدام التابع expand()، ثم نوسّع المستطيل باستخدام الخاصية margin. يجب علينا هنا الضغط على زر Tab بالمستطيل المرسوم لتفعيل هذا الرسم في المشروع التجريبي: نجد المقياس ونثبّته في النطاق الأقصى/الأدنى الذي حدّدناه اعتمادًا على ما إذا كان المستطيل أوسع أو أطول، إذ يتعلق ذلك بالنسبة إلى أبعاد الشاشة. السكربت الكامل سيكون السكربت الكامل للعملية على النحو الآتي: extends Camera2D @export var move_speed = 30 # سرعة الاستيفاء الخطي lerp لموضع الكاميرا @export var zoom_speed = 3.0 # سرعة الاستيفاء الخطي lerp لتكبير وتصغير Zoom الكاميرا @export var min_zoom = 5.0 # لن تقترب الكاميرا أكثر من هذه القيمة @export var max_zoom = 0.5 # لن تبتعد الكاميرا أكثر من هذه القيمة @export var margin = Vector2(400, 200) # تضمين بعض المساحة العازلة حول الأهداف var targets = [] @onready var screen_size = get_viewport_rect().size func _process(delta): if !targets: return # إبقاء الكاميرا متمركزة بين الأهداف var p = Vector2.ZERO for target in targets: p += target.position p /= targets.size() position = lerp(position, p, move_speed * delta) # العثور على التكبير أو التصغير الذي سيحتوي على جميع الأهداف var r = Rect2(position, Vector2.ONE) for target in targets: r = r.expand(target.position) r = r.grow_individual(margin.x, margin.y, margin.x, margin.y) var z if r.size.x > r.size.y * screen_size.aspect(): z = 1 / clamp(r.size.x / screen_size.x, max_zoom, min_zoom) else: z = 1 / clamp(r.size.y / screen_size.y, max_zoom, min_zoom) zoom = lerp(zoom, Vector2.ONE * z, zoom_speed * delta) # لتنقيح الأخطاء get_parent().draw_cam_rect(r) func add_target(t): if not t in targets: targets.append(t) func remove_target(t): if t in targets: targets.remove(t) ملاحظة: يمكن تنزيل شيفرة المشروع البرمجية من Github. إضافة تصادمات مع خط مرسوم ثنائي الأبعاد سنوضّح فيما يلي كيفية إضافة تصادمات مع خط مرسوم ثنائي الأبعاد باستخدام عقدة Line2D. إعداد العقد سنحتاج لإضافة العقد التالية إلى مشهدنا، ورسم الخط الذي نريده: Line2D StaticBody2D لا داعي لإضافة شكل تصادم إلى الجسم حاليًا. ملاحظة: يمكن استخدام العقدة Area2D عوضًا عن ذلك إذا أردنا اكتشاف التداخل مع الخط بدلًا من التصادم. يجب بعد ذلك إضافة أشكال تصادم إلى الجسم، حيث يوجد لدينا خياران كما سنوضّح فيما يلي. الخيار الأول: استخدام الشكل SegmentShape2D يمثّل الشكل SegmentShape2D شكل تصادم لخط ومقطع، حيث نريد إنشاء تصادم مقاطع لكل زوج من النقاط على الخط. extends Line2D func _ready(): for i in points.size() - 1: var new_shape = CollisionShape2D.new() $StaticBody2D.add_child(new_shape) var segment = SegmentShape2D.new() segment.a = points[i] segment.b = points[i + 1] new_shape.shape = segment الخيار الثاني: استخدام الشكل RectangleShape2D لا يحتوي الشكل SegmentShape2D على أيّ مكوّن للعرض، لذا إذا أردنا أن يكون لتصادم الخطوط ثخانة، فيمكن استخدام تصادم المستطيل بدلًا من ذلك. extends Line2D func _ready(): for i in points.size() - 1: var new_shape = CollisionShape2D.new() $StaticBody2D.add_child(new_shape) var rect = RectangleShape2D.new() new_shape.position = (points[i] + points[i + 1]) / 2 new_shape.rotation = points[i].direction_to(points[i + 1]).angle() var length = points[i].distance_to(points[i + 1]) rect.extents = Vector2(length / 2, width / 2) new_shape.shape = rect ملاحظة: يمكن تنزيل شيفرة المشروع البرمجية من Github. ختامًا بهذا نكون قد وصلنا إلى نهاية المقال الذي حددنا فيه طريقة إعداد الكاميرا لتكون ديناميكية داخل اللعبة، كما تعرفنا على كيفية إضافة تصادمات مع خط مرسوم ثنائي الأبعاد. ترجمة -وبتصرّف- للقسمين Multitarget Camera و Line2D Collision من توثيقات Kidscancode. اقرأ أيضًا المقال السابق: إطلاق المقذوفات وترتيب عرض الكائنات في الألعاب ثنائية الأبعاد دليلك الشامل إلى بناء كاميرا خاصة بشاشات اللمس في محرّك اﻷلعاب جودو تعرف على العقد Nodes في محرك ألعاب جودو Godot
-
سنوضّح في هذا المقال كيفية إعداد خوارزمية للبحث عن المسار للسماح بالتنقل في بيئة قائمة على الشبكة Grid، حيث يوفّر محرّك الألعاب جودو Godot عددًا من الطرق لتحديد المسار، ولكننا سنستخدم في هذا المقال خوارزمية A*، التي لها استخدام واسع في العثور على أقصر مسار بين نقطتين، ويمكن استخدامها في أيّ هيكل بيانات قائمٍ على الرسم البياني Graph، وليس في بيئة شبكية فقط. يُعَد الصنف AStarGrid2D نسخةً متخصصةً من الصنف AStar2D الأعم في جودو، لذا يُعَد إعداده أسرع وأسهل، نظرًا لأنه متخصص للاستخدام مع الشبكة، إذ لسنا مضطرين لإضافة جميع خلايا الشبكة الفردية واتصالاتها يدويًا. إعداد الشبكة يُعَد ضبط حجم الخلايا والشبكة أهم خطوة، حيث سنستخدم الحجم (64, 64) في مثالنا، وسنستخدم حجم النافذة لتحديد عدد الخلايا الملائمة للشاشة، ولكن كل شيء سيعمل بالطريقة نفسها بغض النظر عن حجم الخلية. سنضيف الآن الشيفرة البرمجية التالية إلى العقدة Node2D: extends Node2D @export var cell_size = Vector2i(64, 64) var astar_grid = AStarGrid2D.new() var grid_size func _ready(): initialize_grid() func initialize_grid(): grid_size = Vector2i(get_viewport_rect().size) / cell_size astar_grid.size = grid_size astar_grid.cell_size = cell_size astar_grid.offset = cell_size / 2 astar_grid.update() نقسم حجمَ الشاشة على حجم الخلية cell_size في هذه الشيفرة البرمجية لحساب حجم الشبكة بالكامل، مما يتيح ضبط الخاصية size الخاصة بالصنف AStarGrid2D. تُستخدَم خاصية الإزاحة offset عندما نطلب مسارًا بين نقطتين، ويمثّل استخدام cell_size / 2 حساب المسار من مركز كل خلية بدلًا من زواياها، ويجب استدعاء الدالة update() بعد ضبط أو تغيير خاصيات الصنف AStarGrid2D. رسم الشبكة سنرسم الشبكة على الشاشة في الشيفرة البرمجية للتوضيح، ولكن قد تكون لدينا عقدة TileMap أو أيّ تمثيل مرئي آخر لعالمنا في تطبيق اللعبة. يمكن رسم الشبكة باستخدام الشيفرة البرمجية التالية: func _draw(): draw_grid() func draw_grid(): for x in grid_size.x + 1: draw_line(Vector2(x * cell_size.x, 0), Vector2(x * cell_size.x, grid_size.y * cell_size.y), Color.DARK_GRAY, 2.0) for y in grid_size.y + 1: draw_line(Vector2(0, y * cell_size.y), Vector2(grid_size.x * cell_size.x, y * cell_size.y), Color.DARK_GRAY, 2.0) وسنحصل على الشبكة التالية: رسم المسار نحتاج إلى نقطة بداية ونقطة نهاية للعثور على مسار، لذا علينا إضافة المتغيرات التالية في بداية السكربت: var start = Vector2i.ZERO var end = Vector2i(5, 5) بعد ذلك نضيف الأسطر التالية في الدالة _draw() لإظهار هذه النقاط: draw_rect(Rect2(start * cell_size, cell_size), Color.GREEN_YELLOW) draw_rect(Rect2(end * cell_size, cell_size), Color.ORANGE_RED) يمكننا الآن العثور على المسار بين النقطتين باستخدام التابع get_point_path()، ولكننا نحتاج أيضًا إلى إظهاره، لذا من المهم استخدام العقدة Line2D وإضافة إلى المشهد، حيث يمكن الحصول على المسار وإضافة النقاط الناتجة إلى العقدة Line2D كما يلي: func update_path(): $Line2D.points = PackedVector2Array(astar_grid.get_point_path(start, end)) وسنحصل على النتيجة التالية: وكما نلاحظ، لدينا خطًا قطريًا بين النقطتين لأن المسار يستخدم الخطوط القطرية افتراضيًا، ويمكن تعديل ذلك من خلال تغيير الخاصية diagonal_mode باستخدام القيم التالية: DIAGONAL_MODE_ALWAYS: القيمة الافتراضية، وتستخدم الخطوط القطرية DIAGONAL_MODE_NEVER: تكون الحركة عمودية DIAGONAL_MODE_AT_LEAST_ONE_WALKABLE: تسمح هذه القيمة بالخطوط القطرية، ولكنها تمنع المسار من المرور بين العوائق الموضوعة قطريًا DIAGONAL_MODE_AT_LEAST_ONE_WALKABLE: تسمح بالخطوط القطرية في المناطق المفتوحة فقط، وليس بالقرب من العوائق قد يؤدي تعديل هذه الخاصية إلى إعطاء نتائج مختلفة جدًا، لذا تأكد من التجربة بناءً على الإعداد الذي تستخدمه، لذا يجب إضافة ما يلي في الدالة initialize_grid(): astar_grid.diagonal_mode = AStarGrid2D.DIAGONAL_MODE_NEVER وأصبح لدينا الآن الحركات المتعامدة فقط كما يلي: إضافة العوائق يمكننا أيضًا إضافة العوائق إلى الشبكة، حيث لن يتضمن المسار خليةً ما من خلال وضع علامة عليها بوصفها صلبة Solid، ويمكن التبديل بين القيمتين صلبة وغير صلبة للخلية باستخدام الدالة set_point_solid(). سنضيف الشيفرة البرمجية التالية لرسم الجدران (إن وُجدت) من خلال العثور على الخلايا الصلبة وتلوينها: func fill_walls(): for x in grid_size.x: for y in grid_size.y: if astar_grid.is_point_solid(Vector2i(x, y)): draw_rect(Rect2(x * cell_size.x, y * cell_size.y, cell_size.x, cell_size.y), Color.DARK_GRAY) سنستدعي الآن هذه الدالة في الدالة _draw()، ويمكن بعد ذلك استخدام الفأرة للنقر على الخلايا وتبديل حالتها كما يلي: func _input(event): if event is InputEventMouseButton: # إضافة أو إزالة حائط if event.button_index == MOUSE_BUTTON_LEFT and event.pressed: var pos = Vector2i(event.position) / cell_size if astar_grid.is_in_boundsv(pos): astar_grid.set_point_solid(pos, not astar_grid.is_point_solid(pos)) update_path() queue_redraw() يمكن ملاحظة أننا نستخدم التابع is_in_boundsv() أولًا، مما يمنع حدوث أخطاء إذا نقرنا خارج حدود الشبكة. يمكننا الآن رؤية تأثير العوائق على المسار كما يلي: الاختيار الاستكشافي أو التجريبي Heuristic يُعَد الاختيار الاستكشافي الذي نستخدمه عاملًا مهمًا يؤثر على المسار الناتج، حيث يشير مصطلح Heuristic إلى أفضل تخمين، ويمثل ببساطة الاتجاه الذي يجب أن نجرّبه أولًا عند التحرك نحو الهدف في سياق العثور على المسار. تستخدم المسافة الإقليدية مثلًا نظرية فيثاغورس لتقدير المسار الذي يجب تجربته كما يلي: بينما تأخذ مسافة مانهاتن في حساباتها المسافة في اتجاهات الشمال/الجنوب أو الشرق/الغرب فقط كما يلي: وتعطي طريقة التجزيء الاستكشافية Octile Heuristic المسار التالي: يمكن استخدام الاختيار الاستكشافي باستخدام الخاصية التالية: astar_grid.default_estimate_heuristic = AStarGrid2D.HEURISTIC_OCTILE يعتمد تحديد الخيار الأفضل الذي يعطي المسارات المناسب على طبيعة بيئتنا مثل احتوائها على مساحات مفتوحة واسعة مع القليل من العوائق المنتشرة حولها أم أنها متاهة من الممرات المتعرجة، لذا يجب التأكّد من تجربة مشروعنا أولًا. ملاحظة: يمكن تنزيل شيفرة المشروع البرمجية من Github لتجربة الإعداد، ويمكن استخدام أزرار الفأرة الأيمن/الأوسط لتحريك مواقع النهاية/البداية بالإضافة إلى وضع الجدران. ترجمة -وبتصرّف- للقسم Pathfinding on a 2D Grid من توثيقات Kidscancode. اقرأ أيضًا المقال السابق: مفهوم Coyote Time وكيفية إعداد منصة متحركة في مشهد اللعبة إطلاق المقذوفات وترتيب عرض الكائنات في الألعاب ثنائية الأبعاد إنشاء شخصية وتحريكها في ألعاب المنصات ثنائية الأبعاد
-
سنوضح في هذا المقال تقنية Coyote Time ونضيفها إلى شخصية منصة موجودة مسبقًا، وسنتعرّف على كيفية تحريك المنصات في لعبة المنصات ثنائية الأبعاد. شرح مفهوم Coyote Time قد يكون القفز غير موجود في ألعاب المنصات، حيث لا يتمتع اللاعب بقدرٍ جيد من التحكم، وقد يفشل أحيانًا في القفز من حافة المنصات. يتمثّل الحل لهذه المشكلة في استخدام تقنية Coyote Time التي تمنح اللاعب شعورًا أكبر بالتحكم ومساحة ضئيلة للحركة حول عملية القفز من حواف المنصات، حيث تعمل هذه التقنية بالطريقة التالية: إذا مشى اللاعب بعد حافة منصة، فسنسمح له بالقفز كما لو كان لا يزال على الأرض بعد بضعة إطارات. ملاحظة: أتى اسم هذه التقنية من شخصية الذئب Coyote الكرتونية الشهيرة التي لا تسقط حتى تنظر إلى الأسفل: سنضيف هذه التقنية إلى شخصية منصة موجودة مسبقًا، لذا يمكن الاطلاع على مقال إنشاء شخصية وتحريكها في ألعاب المنصات ثنائية الأبعاد لمعرفة كيفية إعدادها. سنضيف عقدة Timer بالاسم CoyoteTimer ونضبطها على لقطة واحدة One Shot للتعامل مع ضبط الوقت، وتوجد بعض المتغيرات الجديدة التي نحتاجها لتعقّب وقت coyote كما يلي: var coyote_frames = 6 # عدد الإطارات في الهواء المسموح بها للقفز var coyote = false # تتبّع ما إذا كنا في وقت coyote أم لا var last_floor = false # حالة الإطار الأخير على الأرض سنستخدم الإطارات لضبط المدة، وبالتالي يمكننا نقل ذلك إلى وقت ضبط طول العقدة Timer في التابع _ready() كما يلي: $CoyoteTimer.wait_time = coyote_frames / 60.0 سنخزّن القيمة الحالية للتابع is_on_floor() في كل إطار لاستخدامها في الإطار التالي، لذا يجب وضع ما يلي في الدالة _physics_process() بعد الدالة move_and_slide(): last_floor = is_on_floor() يجب التحقق مما إذا كانت الشخصية على الأرض أو في وقت Coyote عندما نكتشف إدخال القفز كما يلي: if Input.is_action_just_pressed("jump") and (is_on_floor() or coyote): velocity.y = jump_speed jumping = true يبدأ وقت Coyote إذا مشى اللاعب بعد حافة المنصة، وبالتالي لم يَعُد على الأرض وكان على الأرض في الإطار السابق. يمكننا التحقق من ذلك وبدء تشغيل المؤقت إذا انتقلنا من الأرض إلى فوقها وفق التالي: if !is_on_floor() and last_floor and !jumping: coyote = true $CoyoteTimer.start() تخبرنا العقدة CoyoteTimer متى تنتهي حالة coyote كما يلي: func _on_coyote_timer_timeout(): coyote = false يمكن تطبيق العملية نفسها على الشخصيات ثلاثية الأبعاد. ملاحظة: نُفِّذت شخصية القسم التالي باستخدام تقنية Coyote Time. كيفية إعداد منصة متحركة في مشهد اللعبة سنوضّح فيما يلي كيفية تحريك المنصات في لعبة المنصات ثنائية الأبعاد، حيث توجد طرق متعددة للتعامل مع ذلك؛ إذ سنستخدم عقد AnimatableBody2D للمنصة ونحرّكها باستخدام عقدة الانتقال التدريجي Tween، مما يسمح بمجموعة متنوعة من أنماط الحركة مع تقليل الشيفرة البرمجية التي يجب كتابتها. ملاحظة: يمكن أيضًا تطبيق هذه التقنية لتحريك المنصات باستخدام عقدة AnimationPlayer بدلًا من عقدة Tween، حيث سيبقى معظم الإعداد نفسه في كلتا الطريقتين، ولكن ستحرّك خاصية موضع position الجسم بدلًا من شيفرة Tween البرمجية. الإعداد سنبدأ بإعداد لعبة منصات بسيطة باستخدام الطريقة المتبعة في مقال إنشاء شخصية وتحريكها في ألعاب المنصات ثنائية الأبعاد، حيث ستعمل هذه الحركة الأساسية بنجاح مع المنصات، وإذا عدّلناها أو استخدمنا طريقتنا الخاصة، فيجب أن يعمل كل شيء بالطريقة نفسها. إنشاء المنصة يحتوي مشهد المنصة على العقد التالية: Node2D المنصة المتحركة MovingPlatform: تكون العقدة الأب Node2D موجودةً لتعمل بوصفها نقطة ارتكاز أو نقطة بداية للمنصة، حيث سنحرّك موضع position المنصة بالتناسب مع هذه العقدة الأب AnimatableBody2D: تمثل هذه العقدة المنصة نفسها، وهي العقدة التي ستتحرّك Sprite2D: يمكن استخدام ورقة الشخصية الرسومية Sprite Sheet هنا أو صور فردية أو حتى عقدة TileMap CollisionShape2D: لا يجب أن نجعل مربع الاصطدام كبيرًا جدًا، وإلّا فسيبدو اللاعب وكأنه يحوم فوق حافة المنصة ستُعِدّ خامة Texture العقدة Sprite2D وشكل الاصطدام بطريقة مناسبة، ونضبط خاصية التزامن مع الفيزياء Sync to Physics على القيمة On في العقدة AnimatableBody2D، مما يضمن تحريك الجسم أثناء خطوة الفيزياء بما أننا نحرّكه في الشيفرة البرمجية، وبالتالي يكون متزامنًا مع اللاعب والأجسام الفيزيائية الأخرى. سنضيف الآن السكربت التالي إلى العقدة الجذر Node2D: extends Node2D @export var offset = Vector2(0, -320) @export var duration = 5.0 func _ready(): start_tween() func start_tween(): var tween = get_tree().create_tween().set_process_mode(Tween.TWEEN_PROCESS_PHYSICS) tween.set_loops().set_parallel(false) tween.tween_property($AnimatableBody2d, "position", offset, duration / 2) tween.tween_property($AnimatableBody2d, "position", Vector2.ZERO, duration / 2) حتى الآن قد استخدمنا بعض خيارات عقد Tween لجعل كل شيء يعمل بسلاسة مثل: set_process_mode(): يضمن حدوث الحركة أثناء خطوة المعالجة الفيزيائية set_loops(): يعمل على تكرار الانتقال التدريجي Tween set_parallel(false): تحدث جميع تغييرات tween_property() في الوقت نفسه افتراضيًا، ويؤدي هذا التابع إلى حدوث هذين الأمرين الواحد تلوَ الآخر، وهما التحرك إلى أحد طرفي الإزاحة ثم العودة إلى البداية يمكن ضبط حركة المنصة باستخدام هاتين الخاصيتين المُصدَّرتين، وهما الإزاحة offset التي يجب أن تضبطها لتحديد المكان الذي يتحرك فيه الانتقال التدريجي Tween بالنسبة لنقطة بدايته، و المدة duration التي يجب أن تضبطها لتحديد المدة التي تستغرقها لإكمال الدورة. سنضيف الان بعض المنصات في المستوى أو العالم الخاص بنا ونجرّبها كما في المثال التالي: 02_moving_platform4.webm ملاحظة: يمكن تنزيل شيفرة المشروع البرمجية من Github. ترجمة -وبتصرّف- للقسمين Coyote Time و Moving Platforms من توثيقات Kidscancode. اقرأ أيضًا المقال السابق: إطلاق المقذوفات وترتيب عرض الكائنات في الألعاب ثنائية الأبعاد إنشاء شخصية وتحريكها في ألعاب المنصات ثنائية الأبعاد مدخل إلى محرك الألعاب جودو Godot تعرف على واجهة محرك الألعاب جودو
-
سنوضّح في هذا المقال من سلسلة دليل جودو كيفية إطلاق المقذوفات وترتيب عرض الكائنات بناءً على موقعها على محور Y في الألعاب ثنائية الأبعاد في محرك الألعاب الشهير جودو Godot. إطلاق المقذوفات من أجل إطلاق مقذوفات، سنحتاج بطبيعة الحال إلى القيام بعدة خطوات تسمح لنا بتنفيذ العملية بنجاح. سنوضّح فيما يلي كيفية إطلاق المقذوفات من اللاعب أو الشخصية المتوحشة وغير ذلك في الألعاب ثنائية الأبعاد. إعداد الرصاصة سنضبط أولًا كائن الرصاصة Bullet الذي يمكننا إنشاء نسخة منه، وفيما يلي العقد التي سنستخدمها: Area2D: Bullet Sprite2D CollisionShape2D يمكن استخدام أي صورة نريدها بالنسبة لخامة العقدة Sprite2D كما هو الحال في المثال التالي: يمكن الآن إعداد العقد وضبط الشخصية الرسومية Sprite وشكل التصادم. إذا كانت الخامة الخاصة بنا موجهةً نحو الأعلى كما هو الحال في المثال السابق، فسنتأكد من تدوير العقدة Sprite بمقدار 90 درجة بحيث تشير إلى اليمين. سنتأكد أيضًا من أنها تتطابق مع الاتجاه الأمامي للعقدة الأب. سنضيف الآن سكربت اتصل من خلاله بالإشارة body_entered الخاصة بالعقدة Area2D كما يلي: extends Area2D var speed = 750 func _physics_process(delta): position += transform.x * speed * delta func _on_Bullet_body_entered(body): if body.is_in_group("mobs"): body.queue_free() queue_free() سنزيل الرصاصة في مثالنا إذا أصابت شيئًا ما، وسنحذف أيّ شيء نشير إليه في مجموعة المتوحشين "mobs"، والذي تصيبه الرصاصة. إطلاق النار يجب إعداد موقع ظهور الرصاصات، لذا من المهم إضافة العقدة Marker2D ووضعها في المكان الذي نريد ظهور الرصاصات فيه، حيث وضعناه عند فوهة البندقية مثلًا، وسميناه بالفوهة "Muzzle": نلاحظ بقاء التحويل transform الخاص بالفوهة موجَّهًا مع اتجاه البندقية نفسه عند دوران اللاعب، وسيكون ذلك مناسبًا عند ظهور الرصاصات، حيث يمكن استخدام هذا التحويل للحصول على الموضع والاتجاه المناسبين. سلنضبط الآن التحويل transform الجديد الخاص بالرصاصة ليكون مساويًا لتحويل الفوهة. ملاحظة: ستنجح هذه الطريقة مع أيّ نوع من الشخصيات، وليس فقط مع أسلوب الدوران والتحريك الموضّح في هذا المقال، فما علينا سوى إرفاق العقدة Marker2D حيث نريد ظهور الرصاصات. سنضيف الآن متغيرًا في سكربت الشخصية للاحتفاظ بمشهد الرصاصة لإنشاء نسخة منه كما يلي: @export var Bullet : PackedScene وتحقق أيضًا من إجراء الإدخال المُعرَّف كما يلي: if Input.is_action_just_pressed("shoot"): shoot() يمكننا الآن إنشاء نسخة من الرصاصة وإضافتها إلى الشجرة في الدالة shoot()، ولكن تُعَد إضافة الرصاصة كابن للاعب من الأخطاء الشائعة: func shoot(): var b = Bullet.instantiate() add_child(b) b.transform = $Muzzle.transform تكمن المشكلة في تأثّر الرصاصات عندما يتحرك اللاعب أو يدور لأنها أبناء للاعب. يمكن إصلاح هذه المشكلة من خلال التأكد من إضافة الرصاصات إلى المستوى العالمي بدلًا من ذلك، حيث سنستخدم owner في هذه الحالة، والذي يشير إلى العقدة الجذر للمشهد الذي يتواجد فيه اللاعب. وكما نلاحظ، سنحتاج أيضًا إلى استخدام التحويل العام الخاص بالفوهة، وإلّا لن تكون الرصاصة موجودةً في المكان الذي نتوقعه. ملاحظة: يمكن تنزيل شيفرة المشروع البرمجية من Github. ترتيب عرض الكائنات بناء على موقعها على محور Y تستخدم العديد من الألعاب ثنائية الأبعاد منظور 3/4، مما يعطي الانطباع بأن الكاميرا تنظر إلى العالم من زاوية معينة، لذا يجب عرض الكائنات الأبعد خلف الكائنات الأقرب، ويعني ذلك عمليًا أننا نريد ترتيب عرضها بناءً على موقعها على محور Y، مما يجعل ترتيب الرسوم مرتبطًا بإحداثيات الكائن على المحور Y، وكلما كان الكائن أعلى على الشاشة، سيكون أبعد؛ وبالتالي سيكون أخفض وفق ترتيب التصيير Render؛ وفيما يلي مثال لهذه المشكلة: تُرسَم هذه الكائنات وفقًا لترتيب التصيير الافتراضي، والذي هو ترتيب الشجرة، حيث يكون ترتيبها في شجرة المشهد كما يلي: يحتوي جودو Godot على خيارٍ مُضمَّن لتغيير ترتيب التصيير، حيث يمكننا تفعيل الخاصية Y Sort Enabled مع أيّ عقدة CanvasItem مثل Node2D أو Control، ثم يمكن فرز جميع العقد الأبناء بناءً على موقعها على محور Y. يمكن تفعيل هذه الخاصية مع عقدة TileMap في المثال السابق، ولكن ستبقى المشكلة موجودةً كما يلي: يعتمد ترتيب الرسم على إحداثيات المحور Y لكل كائن، حيث يكون مركز الكائن افتراضيًا كما هو موضح في الشكل التالي: نريد الآن إعطاء انطباع بأن الكائنات موجودة على الأرض، لذا يمكن حل هذه المشكلة من خلال إزاحة الشخصية الرسومية Sprite لكل كائن بحيث يحاذي موضعُ position الكائن أسفلَ الشخصية الرسومية كما يلي: وبهذا تكون الأمور قد أصبحت أفضل بكثير كما يلي: ملاحظة: يمكن تنزيل شيفرة المشروع البرمجية من Github. ترجمة -وبتصرّف- للقسمين Shooting projectiles و Using Y-Sort من توثيقات Kidscancode. اقرأ أيضًا المقال السابق: إنشاء وحدة تحكم واقعية للسيارات في ألعاب الفيديو ثنائية الأبعاد آلية الالتفاف حول الشاشة وتحريك الشخصيات في الألعاب ثنائية الأبعاد إنشاء شخصية وتحريكها في ألعاب المنصات ثنائية الأبعاد
-
سنوضّح في هذا المقال كيفية الالتفاف حول الشاشة وبرمجة الحركة من الأعلى إلى الأسفل وتحريك الشخصيات بالاعتماد على الشبكة Grid وفي ثمانية اتجاهات مختلفة في الألعاب ثنائية الأبعاد. آلية الالتفاف حول الشاشة يُعَد السماح للاعب بالالتفاف حول الشاشة والانتقال الفوري من أحد جانبي الشاشة إلى الجانب الآخر ميزةً شائعة، وخاصة في الألعاب ثنائية الأبعاد القديمة مثل لعبة باك مان Pac-man، إذ يمكن السماح للاعب بالالتفاف حول الشاشة من خلال اتباع الخطوات. الخطوة الأولى هي بالحصول على حجم الشاشة أو نافذة العرض Viewport كما يلي: @onready var screen_size = get_viewport_rect().size حيث تتوفر الدالة get_viewport_rect() لأي عقدة مشتقة من CanvasItem. أما ثاني خطوة، فتتمثل في مقارنة موضع اللاعب كما يلي: if position.x > screen_size.x: position.x = 0 if position.x < 0: position.x = screen_size.x if position.y > screen_size.y: position.y = 0 if position.y < 0: position.y = screen_size.y وكما نلاحظ، تم استخدام موضع position العقدة الذي يكون عادةً مركزًا للشخصية الرسومية Sprite و/أو مركز الجسم. ثالث خطوة هي عبر تبسيط ما سبق باستخدام الدالة wrapf()، إذ يمكن تبسيط الشيفرة البرمجية السابقة باستخدام الدالة wrapf() في لغة GDScript، والتي تكرّر القيمة بين الحدود المحدّدة. position.x = wrapf(position.x, 0, screen_size.x) position.y = wrapf(position.y, 0, screen_size.y) برمجة الحركة من الأعلى إلى الأسفل إذا أردنا إنشاء لعبة ثنائية الأبعاد من الأعلى إلى الأسفل، فيجب أن تتحكم في حركة الشخصية، لذا لنفترض تحديد إجراءات الإدخال التالية: اسم الإجراء المفتاح أو مجموعة المفاتيح "up" المفتاح W أو ↑ "down" المفتاح S أو ↓ "right" المفتاح D أو → "left" المفتاح A أو ← "click" زر الفأرة 1 سنفترض أيضًا أننا نستخدم عقدة CharacterBody2D. هنا سيكون بإمكاننا أيضًا التحكم في حركة الشخصية باستخدام طرق متعددة اعتمادًا على نوع السلوك الذي تبحث عنه كما سنوضح فيما يلي. الخيار الأول: الحركة في 8 اتجاهات يستخدم اللاعب في هذه الحالة مفاتيح الاتجاهات الأربعة للتحرك (بما في ذلك الاتجاهات القطرية) كما يلي: extends CharacterBody2D var speed = 400 # السرعة بالبكسلات في الثانية func _physics_process(delta): var direction = Input.get_vector("left", "right", "up", "down") velocity = direction * speed move_and_slide() الخيار الثاني: التدوير والتحريك تدوّر الإجراءات لليسار/لليمين "left/right" في هذه الحالة الشخصية وتحرّك الإجراءات للأعلى/للأسفل الشخصية للأمام وللخلف في أيّ اتجاه تواجهه، ويُشار إلى ذلك أحيانًا باسم "الحركة التي تحاكي حركة الكويكبات". extends CharacterBody2D var speed = 400 # سرعة الحركة بالبكسل/ثانية var rotation_speed = 1.5 # سرعة الدوران بالراديان/ثانية func _physics_process(delta): var move_input = Input.get_axis("down", "up") var rotation_direction = Input.get_axis("left", "right") velocity = transform.x * move_input * speed rotation += rotation_direction * rotation_speed * delta move_and_slide() ملاحظة: يَعُد محرّك ألعاب جودو Godot أن الزاوية 0 درجة تؤشّر على طول المحور x، مما يعني أن اتجاه العقدة للأمام (transform.x) يتجه إلى اليمين، لذا يجب التأكد من رسم الشخصية الرسومية لتشير إلى اليمين. الخيار الثالث: التصويب بالفأرة يُعَد هذا الخيار مشابهًا للخيار الثاني، ولكننا نتحكم في دوران الشخصية باستخدام الفأرة هذه المرة، أي أنّ الشخصية تشير دائمًا إلى الفأرة، وتُطبَّق الحركة للأمام/للخلف باستخدام المفاتيح كما في السابق. extends CharacterBody2D var speed = 400 # سرعة الحركة بالبكسل/ثانية func _physics_process(delta): look_at(get_global_mouse_position()) var move_input = Input.get_axis("down", "up") velocity = transform.x * move_input * speed move_and_slide() الخيار الرابع: النقر والتحريك تنتقل الشخصية في هذا الخيار إلى الموقع الذي نقرنا عليه. extends CharacterBody2D var speed = 400 # سرعة الحركة بالبكسل/ثانية var target = null func _input(event): if event.is_action_pressed("click"): target = get_global_mouse_position() func _physics_process(delta): if target: # look_at(target) velocity = position.direction_to(target) * speed if position.distance_to(target) < 10: velocity = Vector2.ZERO move_and_slide() وكما نلاحظ، سنتوقف عن الحركة إذا اقتربنا من موضع الهدف، فإن لم نفعل ذلك، فستهتز الشخصية ذهابًا وإيابًا بحيث تتحرك قليلًا بعد الهدف وتعود، ثم تعاود الكرّة وهكذا. يمكننا اختياريًا استخدام الدالة look_at() لتكون مواجهةً لاتجاه الحركة. ملاحظة: يمكن تنزيل شيفرة المشروع البرمجية من Github. تحريك الشخصيات بالاعتماد على الشبكة Grid سنوضّح فيما يلي كيفية تحريك شخصية ثنائية الأبعاد في نمط شبكي؛ إذ تعني الحركة المعتمدة على الشبكة Grid أو المربع Tile أن موضع الشخصية مقيد، ولا يمكنها الوقوف إلا على مربع معين، كما لا يمكنها أن تكون بين مربعين أبدًا. إعداد الشخصية سنوضح فيما يلي العقد التي سنستخدمها للاعب: Area2D (اللاعب "Player"): يمثّل استخدام العقدة Area2D أنه يمكننا اكتشاف التداخل لالتقاط الأشياء أو الاصطدام بالأعداء Sprite2D: يمكن استخدام ورقة الشخصية الرسومية Sprite Sheet هنا، وسنوضّح إعداد الرسوم المتحركة Animations لاحقًا CollisionShape2D: يجب أن لا نجعل مربع الاصطدام كبيرًا جدًا، حيث ستكون التداخلات من عند المركز لأن اللاعب سيقف في منتصف المربع RayCast2D: للتحقق مما إذا كانت الحركة ممكنة في اتجاه محدّد AnimationPlayer: لتشغيل الرسوم المتحركة الخاصة بمشي الشخصية سنضيف هنا بعض إجراءات الإدخال إلى خريطة الإدخال Input Map، ويمكننا لأجل ذلك استخدام إجراءات up وdown وleft، و right في هذا المثال. الحركة الأساسية سنبدأ بإعداد الحركة على المربعات الواحد تلو الآخر دون أي رسوم متحركة أو استيفاء Interpolation. extends Area2D var tile_size = 64 var inputs = {"right": Vector2.RIGHT, "left": Vector2.LEFT, "up": Vector2.UP, "down": Vector2.DOWN} يجب ضبط حجم المربعات tile_size ليتطابق مع حجم المربعات الخاصة بنا، ويمكن ضبطه في مشروع أكبر باستخدام المشهد الرئيسي عند إنشاء نسخة من اللاعب، ولكن سنستخدم مربعات بحجم 64x64 في مثالنا. يربط القاموس inputs أسماء إجراءات الإدخال مع متجهات الاتجاه، لذا يجب التأكّد من كتابة الأسماء نفسها هنا وفي خريطة الإدخال مع مراعاة استخدام الحروف الكبيرة. func _ready(): position = position.snapped(Vector2.ONE * tile_size) position += Vector2.ONE * tile_size/2 تتيح الدالة snapped() تقريب الموضع إلى أقرب زيادة في المربع، وتضمَن إضافة كمية بمقدار نصف مربع أن يكون اللاعب في مركز المربع. func _unhandled_input(event): for dir in inputs.keys(): if event.is_action_pressed(dir): move(dir) func move(dir): position += inputs[dir] * tile_size تمثل الشيفرة البرمجية السابقة الحركة الفعلية. حيث إذا ظهر حدث إدخال، فسنتحقق من الاتجاهات الأربعة لمعرفة أيّ منها يتطابق مع الحدث، ثم نمرّره إلى الدالة move() لتغيير الموضع. الاصطدام Collision يمكننا الآن إضافة بعض العوائق، حيث يمكن استخدام عقد StaticBody2D لإضافة بعض العوائق يدويًا، مع تفعيل الالتقاط للتأكد من محاذاتها مع الشبكة، أو استخدام TileMap مع تحديد الاصطدامات كما هو الحال في المثال التالي، وسنستخدم عقدة RayCast2D لتحديد السماح بالانتقال إلى المربع التالي. onready var ray = $RayCast2D func move(dir): ray.target_position = inputs[dir] * tile_size ray.force_raycast_update() if !ray.is_colliding(): position += inputs[dir] * tile_size إذا غيّرنا الخاصية target_position الخاصة بعقدة RayCast2D، فلن يعيد محرّك الفيزياء حساب تصادماته حتى الإطار الفيزيائي التالي. تتيح الدالة force_raycast_update() إمكانية تحديث حالة الشعاع مباشرةً، حيث إن لم يكن هناك تصادم، فسنسمح بالتحرك. ملاحظة: تتمثل إحدى الطرق الشائعة الأخرى في استخدام أربع عقد RayCast2D من خلال استخدام عقدة لكل اتجاه. تنشيط الحركة وأخيرًا، يمكننا استيفاء الموضع بين المربعات، مما يعطي إحساسًا سلسًا بالحركة، حيث سنستخدم عقدة Tween لتحريك خاصية الموضع position. var animation_speed = 3 var moving = false سنضيف الآن مرجعًا إلى عقدة Tween ومتغيرًا لضبط سرعة الحركة كما يلي: func _unhandled_input(event): if moving: return for dir in inputs.keys(): if event.is_action_pressed(dir): move(dir) سنتجاهل أي إدخال أثناء تشغيل الانتقال التدريجي Tween ونزيل تغيير الموضع position المباشر حتى يتمكّن الانتقال التدريجي من التعامل معه. func move(dir): ray.target_position = inputs[dir] * tile_size ray.force_raycast_update() if !ray.is_colliding(): #position += inputs[dir] * tile_size var tween = create_tween() tween.tween_property(self, "position", position + inputs[dir] * tile_size, 1.0/animation_speed).set_trans(Tween.TRANS_SINE) moving = true await tween.finished moving = false سنجرِّب الآن انتقالات تدريجية مختلفة للحصول على تأثيرات حركية مختلفة. ملاحظة: يمكن تنزيل شيفرة المشروع البرمجية من Github. تحريك الشخصيات في 8 اتجاهات مختلفة سنوضّح فيما يلي كيفية تحريك شخصية ثنائية الأبعاد في 8 اتجاهات مختلفة، حيث سنستخدم في مثالنا شخصية محارب التي تحتوي على رسوم متحركة في 8 اتجاهات لحالات عدم الحركة والجري والهجوم والعديد من الحالات الأخرى. تُنظَّم الرسوم المتحركة ضمن مجلدات، مع وجود صورة منفصلة لكل إطار. سنستخدم العقدة AnimatedSprite2D وسنسمّي الرسوم المتحركة بناءً على اتجاهها. على سبيل المثال، يشير الرسم المتحرك idle0 إلى اليمين، ثم ننتقل باتجاه عقارب الساعة حتى الوصول إلى الرسم المتحرك idle7، وتختار الشخصية عند تحرّكها رسومًا متحركة بناءً على اتجاه الحركة: سنستخدم الفأرة للتحرك، حيث ستواجه الشخصية الفأرة دائمًا وتتحرك في هذا الاتجاه عندما نضغط على زر الفأرة. يمكن اختيار الرسوم المتحركة التي ستعمل من خلال الحصول على اتجاه الفأرة وربطه مع هذا المجال نفسه من 0 إلى 7؛ إذ تعطي الدالة get_local_mouse_position() موضع الفأرة بالنسبة للشخصية، ويمكننا بعد ذلك استخدام الدالة snappedf() لضبط زاوية متجه الفأرة إلى أقرب مضاعف للزاوية 45 درجة (أو PI/4 راديان) مما يعطي النتيجة التالية: سنقسّم كل قيمة على 45 درجة ( أو PI/4 راديان) ونحصل على النتيجة التالية: في الأخير، يجب ربط المجال الناتج مع المجال 0-7 باستخدام الدالة wrapi()، وسنحصل على القيم الصحيحة، حيث تعطي إضافة هذه القيمة إلى نهاية اسم الرسوم المتحركة ("idle" و "run" وغيرها) الرسم المتحرك الصحيح كما يلي: func _physics_process(delta): current_animation = "idle" var mouse = get_local_mouse_position() angle = snappedf(mouse.angle(), PI/4) / (PI/4) angle = wrapi(int(angle), 0, 8) if Input.is_action_pressed("left_mouse") and mouse.length() > 10: current_animation = "run" velocity = mouse.normalized() * speed move_and_slide() $AnimatedSprite2D.animation = current_animation + str(a) وسنشاهد ما يلي عند اختبار الحركة: الإدخال من لوحة المفاتيح إذا كنا نستخدم عناصر التحكم من لوحة المفاتيح بدلًا من الفأرة، فيمكننا الحصول على زاوية الحركة بناءً على المفاتيح التي نضغط عليها، وتسير بقية العملية باستخدام الطريقة نفسها كما يلي: func _process(delta): current_animation = "idle" var input_dir = Input.get_vector("left", "right", "up", "down") if input_dir.length() != 0: angle = input_dir.angle() / (PI/4) angle = wrapi(int(a), 0, 8) current_animation = "run" velocity = input_dir * speed move_and_slide() $AnimatedSprite2D.play(current_animation + str(angle)) الخاتمة استعرضنا في هذا المقال أساليب مختلفة لتحريك الشخصيات في الألعاب ثنائية الأبعاد باستخدام محرك ألعاب جودو، بما يشمل الالتفاف حول الشاشة، والحركة من الأعلى إلى الأسفل، والحركة الشبكية، والحركة في ثمانية اتجاهات. تساعد هذه الأساليب على تحسين تجربة اللعب وتوفير تحكم دقيق ومتناسق مع نوع اللعبة. يُنصح باختيار الطريقة الأفضل حسب تصميم اللعبة وأسلوب اللعب المطلوب. ويمكن تنزيل شيفرة المشروع البرمجية من Github لمزيد من الفهم. ترجمة -وبتصرّف- للأقسام Screen wrap و Top-down movement و Grid-based movement و 8-Directional Movement/Animation من توثيقات Kidscancode. اقرأ أيضًا المقال السابق: إنشاء شخصية وتحريكها في ألعاب المنصات ثنائية الأبعاد فهم RayCast2D واستخداماتها في محرك ألعاب جودو سحب وإفلات جسم صلب RigidBody2D في جودو إنشاء شخصيات ثلاثية الأبعاد في جودو Godot برمجة لعبة متاهة باستخدام محرك يونيتي Unity
-
سنشرح في هذا المقال والمقالات التي تليه خطوات إعداد وتنفيذ لعبة كلاسيكية في فضاء ثنائي البعد في محرك الألعاب جودو، اللعبة التي سنعمل عليها هي لعبة مركبة فضاء مقاتلة، وفيما يلي لقطة شاشة لما سيكون عليه الحال عند الانتهاء من اللعبة. نقطة الانطلاق لسهولة الفهم سنبني في كل مقال من هذه السلسلة جزءًا من اللعبة، ونضيف الميزات تدريجيًا ونشرح بالتفصيل ما نفعله في كل مرة. وإن وجدتم مشكلة في فهم الجانب البرمجي لأي جزء من المشروع ننصحكم بالاطلاع على مصادر مفيدة مثل توثيق جودو الرسمي، وسلسلة مقالات تعلم جودو على أكاديمية حسوب. كما يمكنكم تحميل مشروع اللعبة كاملًا من مستودعه المخصص على جيتهب، أو من هنا مباشرة classic_shmup.zipوتجربتها لديكم لمزيد من الفهم. إعداد المشروع سنأخذكم في هذا المشروع إلى بناء أولى ألعابكم على محرّك الألعاب جودو. وعلى الرغم أن العمل على هذا المشروع لا يتطلب منكم أي خبرة سابقة، لكن من المفيد قبل البدء امتلاك بعض الأساسيات وقراءة مقال مدخل إلى محرك الألعاب جودو لتعلم كيفية التعامل مع المحرر والواجهة الرسومية لمحرك جودو بسرعة ومرونة. اخترنا أن يكون المشروع ثنائي الأبعاد 2D لأن الألعاب ثلاثية الأبعاد 3D أكثر تعقيدًا بالنسبة للمبتدئين ، لكن سترون لاحقًا أن الكثير من الميزات الأساسية التي ستتعلمونها عند بناء اللعبة هي نفسها في حالة الألعاب ثنائية وثلاثية الأبعاد. لهذا ينصح أن تتقنوا أولًا خطوات العمل على لعبة متكاملة ثنائية الأبعاد، وبعدها سيكون الانتقال إلى الفضاء ثلاثي البعد أسهل. دعونا نبدأ العمل، لنفتح الآن محرّك البحث جودو ونبدأ مشروعًا جديدًا . يمكن تسمية المشروع بأي اسم تختارونه، لكننا سنختار لمشروعنا اسم Classic Shump لأننا سنطور لعبة كلاسيكية مبنية على التصويب أو إطلاق النار إلى الأعلى. تحميل أصول اللعبة Assets يمكنكم تحميل المحلقات أو الأصول الخاصة باللعبة من صور وأيقونات مختلفة من موقع Mini Pixel Pack by Grafxkid، بعد التحميل علينا أن نستخرج الملفات وننسخها إلى المشروع بسحب المجلد وإفلاته ضمن نافذة مدير الملفات File System لتبدأ عمليه استيرادها لداخل اللعبة. إعدادات المشروع ننتقل الآن إلى القائمة مشروع Project، ثم نختار إعدادات المشروع Project Settings وننقر على زر التبديل إعدادات متقدمة Advanced settings في الزاوية العليا اليسارية، ونضبط ما يلي في قسم النافذة window الموجود ضمن قسم لإظهار Display كما يلي: عرض منفذ العرض viewport width وطول منفذ العرض viewport height على القيمتين 240 و 320 تجاوز عرض النافذة window width override وتجاوز ارتفاع النافذة window height override على القيمتين 480 و 640 على التوالي الخيار تمدد stretch ضمن القسم وضع mode على القيمة canvas_items لجعل عناصر المشهد اللعبة تتمدد وتتناسب مع حجم الشاشة ستضمن هذه الإعدادات أبعادًا صحيحة للعبة، لأننا نستخدم ضمن اللعبة أيقونات من البكسلات وهي بحد ذاتها صغيرة جدًا، لهذا ستكون هذه القيم مناسبة لعرضها. لكن قد تكون هذه الأبعاد صغيرة بالنسبة إلى الشاشات الحديثة، لهذا ضبطنا بعض الإعدادات الأخرى حتى نتمكن من تغيير الأبعاد بأسلوب متناسب معها. لنضبط مثلًا أبعاد التجاوز override على القيم 720x960 إن كانت شاشتنا بدقة 1080p وسنكون قادرين على تغيير حجم النافذة عند تشغيل اللعبة. الآن لننتقل ضمن إعدادات المشروع للقسم معالجة Rendering ثم نختار ملمس Texture ونضبط الخيار تصفية الملمس الافتراضي Default Texture Filter على القيمة Nearest. سيضمن ذلك بقاء الأيقونات جميلة لأن المحرك سيعرض البكسلات كما هي بدون تنعيم وبهذا تبقى الحواف واضحة كما في الصورة اليمنية لا اليسارية: ننقر الآن على تبويب خريطة الإدخال Input Map في إعدادت المشروع حيث سنضبط هنا عناصر الإدخال التي نستخدمها في اللعبة. نكتب كل كلمة مما يلي right و left و up و down و shoot على حدة في صندوق إضافة إجراء جديد Add New Action ثم ننقر بعدها المفتاح Enter لتتكون لدينا خمسة إجراءات، أربعة منها للتحرك في الاتجاهات المختلفة وواحدة للإطلاق. ننقر بعد ذلك الزر + إلى جانب كل إجراء ثم ننقر أي مفتاح من من لوحة المفاتيح كي نعينه لتنفيذ هذا الإجراء. من المفترض أن نرى نتيجة مشابهة لما يلي: بإمكانكم اختيار المفاتيح التي تشاؤنها إن لم تجدوا أن المفاتيح التي عيّناها مناسبة لكم. الخاتمة بدأنا في هذا المقال أولى خطواتنا في تطوير لعبة سفينة فضاء مقاتلة ثنائية الأبعاد بجودو، وتعلمنا طريقة تحميل أصول اللعبة Assets، وضبط إعدادات المشروع المناسبة للعبتنا والتعامل مع إجراءات الدخل، وبعد أن أنهينا هذه الإعدادات الأساسية سنكون جاهزين في المقال التالي لإنشاء شخصية سفينة الفضاء التي سيتحكم بها اللاعب. ترجمة -وبتصرف- للمقالين: Your first 2D game و Project setup اقرأ أيضًا المقال السابق: تعلم إدارة الصوت في جودو مدخل إلى محرك الألعاب جودو Godot تنظيم مشروع جودو البدء بتطوير لعبةبسيطة ثنائية البعد عبر محرك الألعاب Godot
-
سنتعرف في مقال اليوم على العقدة RayCast2D في محرك جودو وكيفية استخدامها بكفاءة في تطوير الألعاب ثنائية الأبعاد، من أجل كشف تصادم الأشعة Raycasting الذي يفيدنا في العديد من حالات الاستخدام. أهمية العقدة RayCast2D في تطوير الألعاب تمثل العقدة RayCast2D في محرك ألعاب جودو شعاعًا ينطلق من نقطة الأصل الممثلة بمركز العقدة إلى نقطة نهاية، والشعاع ray هو خط افتراضي ينطلق من نقطة باتجاه زاوية معينة ويمتد في الفضاء، ويمكننا التحقق فيما إذا كان هذا الشعاع قد اصطدم بشيء ما في المشهد أو وصل إلى نهايته دون أي تصادم. تعد هذه التقنية أساسية في العديد من أنواع الألعاب، مثل ألعاب التصويب أو ألعاب المنصات، حيث يمكن استخدامها للكشف الاصطدامات collisions بين الكائنات أو الأسطح، كأن نحتاج لمعرفة إذا كان اللاعب يرى العدو أو إذا كان يلمس الأرضية أم لا، كما يمكن أن نتحقق من خلالها فيما إذا كانت القذيفة التي أطلقناها قد أصابت هدفًا معينًا. أهم خصائص العقدة RayCast2D تتضمن العقدة RayCast2D في جودو مجموعة من الخصائص المهمة التي تساعدنا على ضبط سلوك الشعاع بشكل دقيق. دعونا ننشئ مشروع جديد للعبة ثنائية الأبعاد، ونضيف للمشهد عقدة RayCast2D ونتحقق من خصائصها الظاهرة في الفاحص Inspector كما يلي: فيما يلي الخاصيات الرئيسية التي ستحتاج إلى فهمها للتعامل مع هذه العقدة: الخاصية Enabled تستخدم للتحكم في تفعيل أو تعطيل شعاع RayCast، فعند تفعيل هذه الخاصية، سيبدأ الشعاع في الكشف عن التصادمات مع الأجسام في كل إطار فيزيائي. وإذا ألغينا تفعيلها فسيتعطل عمل كشف تصادم الأشعة. الخاصية Exclude Parent عند تفعيل هذه الخاصية، سيتجاهل الشعاع التصادم مع العقدة الأب المباشرة. وتكون هذه الخاصية مفعَّلة افتراضيًا لتجنب الكشف عن تصادمات غير مرغوب بها مع الكائن الذي يحتوي على الشعاع. الخاصية Target Position تحدد هذه الخاصية نقطة نهاية الشعاع بالنسبة لموضع العقدة نفسه، أي باستخدام الإحداثيات المحلية، على سبيل المثال، إذا كانت قيمتها (250,0) فسيمتد الشعاع أفقيًا لليمين لمسافة 250 وحدة وهي نقطة الوجهة للشعاع في الإحداثيات المحلية. ملاحظة: تمثل الإحداثيات المحلية Local Coordinates موقع العقدة بالنسبة لنقطة الأصل الخاصة بالعقدة الأم، بينما تمثل الإحداثيات العامة Global Coordinates الموقع المطلق للعقدة داخل المشهد بأكمله. لنلاحظ أيضًا القسم بعنوان Collide With ضمن الفاحص، ففي هذا القسم يمكننا تحديد أنواع الكائنات التي يجب أن يتفاعل معها الشعاع، حيث سيكتشف الشعاع افتراضيًا الأجسام الفيزيائية Bodies فقط مثل KinematicBody2D أو RigidBody2D، ولو أردنا منه اكتشاف المناطق أيضًا مثل Area2D فعلينا تفعيل الخيار Area. دوال مفيدة للعقدة RayCast2D يمكن الاطلاع على القائمة الكاملة لدوال العقدة RayCast2D في توثيق واجهة برمجة التطبيقات API، ولكن سنوضح تاليًا بعض الدوال المفيدة للتعامل مع التصادمات: is_colliding(): دالة منطقية تتيح معرفة فيما إذا كان الشعاع يصطدم بشيء ما get_collision_point(): إذا اصطدم الشعاع بشيء ما ستعيد هذه الدالة موضع التصادم في الإحداثيات العامة get_collider(): إذا اصطدم الشعاع بشيء ما ستعيد هذه الدالة مرجعًا إلى الكائن المتصادم get_collision_normal(): تعيد هذه الدالة الشعاع الناظم Normal على الكائن المتصادم عند نقطة الاصطدام أمثلة عملية توجد العديد من الاستخدامات العملية المفيدة لتقنية كشف تصادم الأشعة مثل الرؤية Visibility أي هل يمكن للكائن A أن يرى الكائن B أم أن هناك عائقًا بينهما يحول دون ذلك، والقرب Proximity أي هل اللاعب قريب من جدار أو أرض أو عائق وغير ذلك من الاستخدامات المختلفة. سنوضح فيما يلي بعض الأمثلة العملية المفيدة. المثال الأول: إطلاق النار تواجه المقذوفات سريعة الحركة مشكلة تسمى Tunneling، فهي تتحرك بسرعة كبيرة بحيث لا يمكنها اكتشاف الاصطدام في إطار واحد وبالتالي سيؤدي هذا لمرورها عبر العوائق أو الأسطح بدل أن تصطدم بها، في هذا الحالة يمكننا استخدام العقدة Raycast2D لتمثيل حركة المقذوف على شكل مسار أو شعاع مستمر مثل شعاع الليزر وبهذا نضمن اكتشاف تصادماته بدقة حتى عند السرعات العالية. يمثّل الشكل التالي شخصية اللاعب حيث أضفنا عقدة Raycast2D عند نهاية السلاح، وضبطنا موضع الهدف target_position على القيمة (250,0)لضبط اتجاه الشعاع الذي يُطلق من السلاح. إذا أطلق اللاعب قذيفة، فيجب التحقق فيما إذا كان الشعاع يصطدم بشيء ما كما يلي: func _input(event): if event.is_action_pressed("shoot"): if $RayCast2D.is_colliding(): print($RayCast2D.get_collider().name) المثال الثاني: اكتشاف حافة منصة لنفترض وجود عدو يمشي على منصة ضمن لعبة ما، لكننا لا نريده أن يسقط من حافة هذه المنصة بل نريده أن يرتد ويتحرك في الاتجاه المعاكس، لذا سنضيف إلى العدو عقدتين من النوع Raycast2D كما يلي: نتحقق متى سيتوقف الشعاع عن الاصطدام بأي شيء في سكربت العدو من خلال الدالة ()is_colliding، ففي حال أعادت الدالة false، فهذا يعني أننا وجدنا الحافة ويجب علينا الالتفاف: func _physics_process(delta): velocity.y += gravity * delta if not $RayRight.is_colliding(): dir = -1 if not $RayLeft.is_colliding(): dir = 1 velocity.x = dir * speed $AnimatedSprite.flip_h = velocity.x > 0 velocity = move_and_slide(velocity, Vector2.UP) يعمل الكود أعلاه على تحريك العدو على المنصة مستعينًا بشعاعي Raycast2D للكشف عن الوصول لحافة المنصة، فإذا لم يصطدم الشعاع الأيمن بشيء سيتحرك العدو لليسار، وإذا لم يصطدم الشعاع الأيسر بشيء سيتحرك العدو لليمين. وسيبدو الأمر أثناء العمل كما يلي: الخاتمة نحتاج إلى تنفيذ تقنيات كشف التصادم في معظم أنواع الألعاب، لذا من الضروي أن نفهم جيدًا كيفية إعداد واستخدام العقدة RayCast2D في جودو ونوظفها بشكل صحيح لحل مشكلات شائعة في تطوير الألعاب مثل التصويب واكتشاف حواف المنصات وتجنب العوائق وغيرها من الحالات. ترجمة -وبتصرّف- للقسم RayCast2D من توثيقات Kidscancode. اقرأ أيضًا التفاعل بين الشخصيات والأجسام الصلبة في جودو استخدام RigidBody2D في جودو للتوجه نحو هدف والتحرك نحوه ترتيب معالجة العقد والتنقل في شجرة المشاهد في Godot تحريك سفينة فضاء باستخدام RigidBody2D في جودو
-
عند تطوير الألعاب باستخدام جودو، من الشائع أن نربط الأصوات بالأحداث التي تقوم بها الشخصيات، كتشغيل صوت معين عند موت الشخصية وصوت آخر عندما تنفذ هجومًا معينًا. ونستخدم غالبًا العقدة AudioStreamPlayer لتشغيل هذه الأصوات لكن هناك مشكلة شائعة قد تواجهنا، فعند إزالة الشخصية من المشهد بسبب موتها أو لأي سبب آخر ستزال كل العقد التابعة لها، بما في ذلك عقدة مشغل الصوت. ونتيجة لذلك، سيتوقف الصوت فجأة، حتى لو لم يكن قد اكتمل تشغيله بعد، وهذه التجربة قد تكون مزعجة للاعب، لأنها تجعل اللعبة تبدو غير طبيعية. سنشرح في الفقرات التالية طريقة مناسبة لحل هذه المشكلة. مشروع مشغل الصوت سنعتمد على عقدة AudioStreamPlayer مستقلة يمكن وضعها في أي مكان داخل شجرة المشهد Scene Tree في محرك ألعاب جودو، لكن من الأفضل أن تكون هذه العقدة مستقلة عن الكائن أو الشخصية. بمعنى ستكون هذه العقدة مسؤولة عن تشغيل المقاطع الصوتية المتعلقة بالأحداث المختلفة في اللعبة، مثل صوت موت الشخصية أو تأثيرات البيئة، دون أن تتأثر بإزالة الكائنات من المشهد. لتحقيق ذلك سننشئ مشروع جودو جديد وننشئ ضمنه مشهد جديد ونحفظه باسم audio_demo.tscn أو أي اسم آخر مناسب ونضيف له مجموعة عقد وفق التسلسل الهرمي التالي: AudioDemo (MarginContainer) ├── CenterContainer (CenterContainer) │ └── GridContainer (GridContainer) ├── CanvasLayer (CanvasLayer) │ └── HBoxContainer (HBoxContainer) │ ├── Label (Label) │ ├── Label2 (Label) │ ├── VSeparator (VSeparator) │ ├── Label3 (Label) │ └── Label4 (Label) يتكوّن المشروع من عقدة جذر من نوع MarginContainer سنسميها AudioDemo، وهي المسؤولة عن تنظيم المحتوى. وننشئ بداخلها عقدة ابن من نوع CenterContainer لمحاذاة المحتويات في المنتصف، والتي سنصيف لها عقدة ابن جديدة GridContainer لتوليد الأزرار الخاصة بتشغيل الملفات الصوتية. كما سنضيف عقدة CanvasLayer لعرض واجهة المستخدم ونضيف لها عقدة ابن من نوع HBoxContainer وضمنها عدة عناصر تسمية Label لعرض إحصائيات عن الملفات الصوتية التي نشغلها، وعدد الملفات الصوتية الموجودة في قائمة الانتظار وعقدة VSeparator للفصل بين العناصر. بعدها نضيف مجلد الموارد assets الذي يتضمن مجموعة ملفات صوتية، ويمكنك الحصول عليه من خلال تحميل هذا المشروع من مستودع جيتهب أو من هنا مباشرة audio_manager.zip. توضح الصورة التالية شكل واجهة مشروعنا الذي سنبنيه بجودو، حيث سيعرض زر لكل ملف صوتي ضمن المجلد assets ويوّلد وفقًا لعددها شبكة مكونة من مجموعة من الأزرار التي تشغل كل ملف صوتي بمجرد النقر على كل منها. وسنرى في أعلى نافذة المشروع إحصائيات عن مدير الصوت. الكود البرمجي لمدير الصوت بعد إضافة العقد المطلوبة وتخصيصها نضيف في البداية سكريبت جديد في محرر جودو ونحفظه باسم audio_manager.gd ونكتب فيه الكود التالي: extends Node var num_players = 8 var bus = "master" var available = [] # المشغلات المتاحة var queue = [] # رتل الأصوات التي ستُشغل func _ready(): # AudioStreamPlayer ننشئ حلقة من العقد for i in num_players: var player = AudioStreamPlayer.new() add_child(player) available.append(player) player.finished.connect(_on_stream_finished.bind(player)) player.bus = bus func _on_stream_finished(stream): # نجعل المشغل متاحًا مجددًا بعد الانتهاء من تشغيل المقطع الصوتي available.append(stream) func play(sound_path): queue.append(sound_path) func _process(delta): # نشغل الأصوات في الرتل إن وجد أي لاعب if not queue.empty() and not available.empty(): available[0].stream = load(queue.pop_front()) available[0].play() available.pop_front() أنشأنا في الكود السابق مجموعة من عقد AudioStreamPlayer وعددها هو num_players وأسندنا لها القيمة الافتراضية 8 وخزناها في قائمة باسم available. كلما أردنا تشغيل صوت نضيفه لرتل أو قائمة انتظار باسم queue مهمته تخزين الأصوات التي نريد تشغيلها لاحقًا، بعد انتهاء تشغيل أي صوت، يعاد المشغل إلى القائمة available ليكون جاهزًا للاستخدام مجددًا. سنضبط هذا السكريبت حتى يُحمّل تلقائيًا Auto-load من إعدادات المشروع كي نتمكن من استدعائه من أي مكان بسهولة، للقيام بذلك سنفتح إعدادات المشروع من خلال القائمة Project ثم Project Settings، نذهب بعدها إلى تبويب Global ثم لتبويب التحميل التلقائي AutoLoad كما في الصورة التالية ونختار ملف السكريبت audio_manager.gd من ملفات المشروع، وفي خانة Node Name نمنحه اسمًا سهل التمييز مثل AudioManager ثم نضغط على Add، وبهذا سيضاف السكربت إلى قائمة التحميل التلقائي، وستُفعَّل خانة Enabled تلقائيًا. بهذا أصبح عندنا نظام صوت مركزي ثابت ومتاح في كل مكان داخل اللعبة، ويمكن أن نستدعيه من أي مكان من مشروعنا نريد فيه تشغيل الصوت بكتابة التالي: AudioManager.play("res://path/to/sound") ملاحظة: يمكن سحب ملفات الصوت مباشرة إلى المحرر النصي في محرك جودو، مما يتيح لنا لصق المسار الخاص بالملف الصوتي في السكريبت بسهولة بدلًا من كتابة المسار يدوياً. الكود البرمجي لواجهة المشروع الديناميكية الخطوة التالية التي سنقوم بها هي توليد الواجهة الديناميكية المكونة من زر لتشغيل كل ملف صوتي، لتحقيق ذلك نلحق سكريبت للعقدة الجذر AudioDemo ونكتب فيه الكود التالي لتشغيل كافة الملفات الصوتية الموجودة في مجلد المشروع: extends MarginContainer # المجلد الذي يحتوي على الملفات الصوتية @export var sound_dir: String = "res://assets" func _ready(): # نحمل كل الملفات الصوتية الموجودة في المجلد var dir = DirAccess.open(sound_dir) if dir: dir.list_dir_begin() var file_name = dir.get_next() while file_name != "": if file_name.get_extension() in ["wav", "ogg"]: add_button(file_name) file_name = dir.get_next() dir.list_dir_end() func add_button(file_name): # إضافة زر لتشغيل الملف الصوتي المخصص له var b = Button.new() $CenterContainer/GridContainer.add_child(b) b.add_theme_font_override("font", load("res://assets/Poppins-Medium.ttf")) b.text = file_name b.pressed.connect(on_audio_button_pressed.bind(b)) func on_audio_button_pressed(button): # تشغيل الصوت المرتبط بالزر var path = sound_dir + "/" + button.text AudioManager.play(path) func _process(delta): # تحديث عدد المشغلات المتاحة وعدد الأصوات في قائمة الانتظار $CanvasLayer/HBoxContainer/Label2.text = str(AudioManager.available.size()) $CanvasLayer/HBoxContainer/Label3.text = str(AudioManager.queue.size()) الخاتمة أنشأنا في هذا المقال نظامًا متكاملًا لإدارة وتشغيل المؤثرات الصوتية في محرك جودو بطريقة مستقرة تضمن استمرار تشغيل الأصوات حتى بعد إزالة الشخصية أو العنصر المرتبط بها، مما يحسّن تجربة اللعب ويجعلها أكثر واقعية وذلك من خلال استخدام التحميل التلقائي للسكريبت الذي يوفّر علينا الجهد في تكرار الكود، ويمنحنا تحكمًا مركزيًا بالملفات الصوتية ضمن اللعبة. ترجمة -وبتصرف- للمقال: Audio manager اقرأ أيضًا المقال السابق: تحريك جسم صلب RigidBody2D بواقعية في الفضاء باستخدام جودو الطريقة الصحيحة للتواصل بين العقد في جودو Godot مدخل إلى محرك الألعاب جودو Godot إضافة النقاط واللعب مجددًا وتأثيرات الصوت للعبة 3D ضمن جودو إضافة المؤثرات الصوتية للعبة المطورة باستخدام بايثون ومكتبة Pygame
-
نشرح في هذا المقال طريقة إنشاء جسم يمثل سفنية تتحرك بطريقة واقعية في الفضاء باستخدام عقدة الجسم الصلب RigidBody2D، حيث يوفر محرك الألعاب جودو أنواعًا مختلفة من الأجسام الفيزيائية ومن ضمنها عقدة RigidBody2D التي تناسب جسمًا يتحرك تلقائيًا وفقًا لقوانين الفيزياء. قد يكون استخدام هذه العقدة مربكًا بعض الشيء لأنها غير قابلة للتحريك المباشر بطريقة مشابهة لعقدة الشخصية CharacterBody2D ومن يتحكم بالكامل هو محرك فيزياء جودو الداخلي Godot physics engine، وبالتالي لا يمكننا ببساطة تغيير موقعها مباشرة من أجل تحريكها بل علينا تطبيق قوى فيزيائية عليها لتحقيق الحركة المطلوبة. ننصح قبل بدء العمل بإلقاء نظرة على توثيق الواجهة البرمجية RigidBody2D لفهم خصائصها بشكل أعمق. بناء الحركة سننشئ مشهد جديد ثنائي الأبعاد في جودو ونستخدم فيه العقد التالية: RigidBody2D (Ship) ├── Sprite2D └── CollisionShape2D وإليكم وظيفة كل عقدة منها: تمثل RigidBody2D(Ship) العقدة الرئيسية للجسم الصلب الذي نريد تحريكه، والذي يتفاعل مع البيئة ويتأثر بقوانين الفيزياء تمثل العقدة Sprite2D الشكل المرئي للجسم الصلب، وهي في حالتنا صورة سفينة الفضاء التي تتحرك تلقائيًا مع الجسم الفيزيائي الصلب تستخدم العقدة CollisionShape2D لتحديد شكل وحجم منطقة التصادم للجسم الصلب، وهي ضرورية كي تتعرف الفيزياء على حدود الجسم وتتفاعل معه بشكل صحيح عند اصطدامه بأجسام أخرى توجيه الشخصية عندما نحرك جسم صلب RigidBody2D في جودو، فإن اتجاهه الأمامي الافتراضي أي الاتجاه الذي يتحرك فيه يُعد دومًا هو الاتجاه الموجب للمحور X أي نحو اليمين في المشهد، لذا علينا في البداية توجيه الجسم الصلب بالشكل الصحيح إذا لم يكن كذلك. هذا الأمر مهم لأننا عندما نطبق قوى أو سرعة على الجسم فإن هذه القيم تُحسب بناءً على المحاور المحلية للجسم، لذا إذا لم يكن الجسم موجّهًا أصلاً نحو اليمين فستكون حركته غير صحيحة أو غير متوقعة. وإن كانت الأيقونة أو صورة الشخصية تتجه باتجاه معاكس، علينا أن ندوّر العقدة Sprite2D وليس الجسم الأب نفسه RigidBody2Dحتى نوجهها بالشكل الصحيح. كما سنستخدم المدخلات التالية في خريطة الإدخال Input Map: المُدخل المفتاح thrust w أو ↑ rotate_right d أو → rotate_left a أو ← بعدها، سنضيف سكريبت إلى الجسم الصلب ونعرّف فيه بعض المتغيرات: extends RigidBody2D @export var engine_power = 800 @export var spin_power = 10000 var thrust = Vector2.ZERO var rotation_dir = 0 يحدد أول متغيران كيفية التحكم بحركة سفينة الفضاء. إذ يتحكم المتغير engine_power بالتسارع أي سرعة الحركة للأمام، بينما يتحكم المتغير spin_power بسرعة دوران السفينة. ويُضبط كل من thrust و rotation_dir بناءً على مدخلات المستخدم، وهذا ما سنفعله تاليًا: func get_input(): thrust = Vector2.ZERO if Input.is_action_pressed("thrust"): thrust = transform.x * engine_power rotation_dir = Input.get_axis("rotate_left", "rotate_right") في البداية، تكون قيمة thrust صفرًا، مما يعني أن السفينة الفضائية لا تتحرك إلى الأمام. عندما يضغط المستخدم مفتاح الدفع مثل السهم العلوي أو مفتاح W، فإننا نضبط thrust بحيث يحدد الشعاع الذي يوجه الحركة الأمامية للسفينة. في الوقت نفسه، تتغير قيمة rotation_dir بمقدار 1 بناءً على مفتاح الإدخال الذي يضغط عليه المستخدم، سواء كان لليمين أو اليسار. يمكن للسفينة الفضائية البدء بالطيران عند تطبيق القيم التالية في الدالة (physics_process(delta_. func _physics_process(_delta): get_input() constant_force = thrust constant_torque = rotation_dir * spin_power ستحلق المركبة الآن في الفضاء، لكن سنجد صعوبة في التحكم بها. حيث سيكون الدوران سريعًا جدًا، وستتسارع بشدة لتغادر الشاشة بعدها، وهنا لا بد من التوقف عن تطبيق فيزياء الفضاء الحقيقية، فطالما أنه لا احتكاك في الفضاء ستتسارع المركبة بسرعة، لهذا من الأسهل في مثالنا أن ندفع المركبة لتتوقف عندما لا تطبَّق عليها قوة دفع، ويمكننا تنفيذ هذا الأمر باستخدام التخميد damping. لننتقل إلى خصائص العقدة ومنها إلى Linear ثم Damp و Angular ثم Damp ونضبط قيمتي هاتين الخاصيتين على 1 و 2 على الترتيب. تحدّ هاتان القيمتان من سرعة الحركة وسرعة الدوران مما يسبب توقف السفينة الفضائية. ملاحظة: يمكن أن نجرّب تغيير قيم هذه الخاصيات ونرى كيف تتفاعل مع engine_power و spin_power. العودة إلى الشاشة تشبه عملية إعادة سفينة الفضاء إلى الجهة المقابلة من الشاشة بعد خروجها من أحد الأطراف انتقالًا لحظيًا عبر المكان. لكن إن حاولنا تعديل خاصية position مباشرةً، فستقفز السفينة فورًا إلى الموقع الجديد، وقد يؤدي ذلك إلى سلوك غير متوقع، لأن محرّك فيزياء جودو يستمر في التحكم بحركة الجسم الصلب. للتغلب على هذه المشكلة، نستخدم دالة رد النداء ()integrate_forces_ الخاصة بالعقدة RigidBody2D، والتي تتيح لنا تعديل الخصائص الفيزيائية مثل الموقع والسرعة بشكل مباشر ومتزامن مع دورة محرك الفيزياء دون أن تتعارض معها. لننتقل الآن إلى screensize في أعلى السكريبت: @onready var screensize = get_viewport_rect().size نضيف بعد ذلك دالة جديدة باسم ()integrate_forces_: func _integrate_forces(state): var xform = state.transform xform.origin.x = wrapf(xform.origin.x, 0, screensize.x) xform.origin.y = wrapf(xform.origin.y, 0, screensize.y) state.transform = xform نلاحظ أن الدالة ()integrate_forces_ تستقبل معاملًا باسم state، وهو كائن من النوع PhysicsDirectBodyState2D يمثل الحالة الفيزيائية الحالية للجسم الصلب. يحتوي هذا الكائن على معلومات مثل موضع الجسم position والقوى المؤثرة forces والسرعة velocity في الاتجاهات المختلفة وغيرها من الخصائص الفيزيائية. وبالتالي يمكن أن نحصل من خلال state على التحويل الحالي للجسم current transform أي موقعه واتجاهه، ثم نعدّل إحداثيات الموقع باستخدام الدالة ()wrapf لتطبيق التفاف حول الشاشة مما يعني أن الجسم سيتجاوز حافة الشاشة ويظهر من الجهة المقابلة لإعطاء تأثير الحركة المستمرة. وأخيرًا، نُعيد ضبط التحويل الجديد داخل state لضمان استمرار حركة الجسم بشكل طبيعي وواقعي. ستبدو الحركة كما في الصورة التالية: الانتقال الفوري لنلقِ نظرة على مثال آخر يوضح استخدام ()integrate_forces_ لتغيير حالة الجسم دون مشكلات. لنضف آلية انتقال آني أو فوري بحيث يتمكن اللاعب من نقل المركبة الفضائية فورًا إلى موقع عشوائي داخل الشاشة عند الضغط على مفتاح مخصص. نضيف بداية متغيرًا جديدًا: var teleport_pos = null نحضّر تاليًا موقعًا عشوائيًا ضمن الدالة ()get_input: if Input.is_action_just_pressed("warp"): teleport_pos = Vector2(randf_range(0, screensize.x), randf_range(0, screensize.y)) وأخيرًا وضمن الدالة ()integrate_forces_ سنستخدم teleport_position إن كان موجودًا ومن ثم نمسحه: if teleport_pos: physics_state.transform.origin = teleport_pos teleport_pos = null وبهذا نكون قد أضفنا ميزة الانتقال الفوري للمركبة الفضائية بشكل آمن ومتوافق مع نظام فيزياء جودو. الخاتمة بهذا نكون وصلنا لختام هذا المقال الذي استعرضنا فيه كيفية استخدام محرك الفيزياء في جودو لتحريك الأجسام الصلبة والتحكم بها بشكل دقيق لتوفير تجربة حركة تفاعلية واقعية ضمن بيئات ثنائية وثلاثية الأبعاد مع التحكم الكامل في حركة الأجسام. بإمكانك تحميل المثال كاملًا عبر مستودعه على جيتهب أو من هنا مباشرة asteroids_physics.zip ترجمة -وبتصرف- للمقال: Asteroids-style Physics (using RigidBody2D) اقرأ أيضًا المقال السابق: التفاعل بين الشخصيات والأجسام الصلبة في جودو بناء وتنفيذ لعبة ثنائية الأبعاد في جودو العمل مع إجراءات الدخل Inputs Actions في جودو تحريك الشخصية في لعبة 3D باستخدام محرر التحريك في جودو التحريك باستخدام SpriteSheet و AnimationTree StateMachine في جودو
-
بدأنا في المقال السابق شرح بعض المفاهيم الرياضية الأساسية التي يحتاج مطور الألعاب لمعرفتها، مثل الاستيفاء الخطي linear interpolation -أو lerp اختصارًا- والجداء النقطي Dot Product، والجداء الشعاعي Cross Product. وسنستكمل في مقال اليوم شرح مفهوم رياضي مهم وهو التحويلات الهندسية Transforms الذي يسمح لنا بتغيير مكان أو شكل الأشياء في الفضاء باستخدام المصفوفات. متطلبات العمل قبل المتابعة في قراءة هذا المقال، يجب توفر دراية جيدة عن اﻷشعة vectors وكيفية استخدامها في تطوير اﻷلعاب. لهذا ننصح بالعودة إلى سلسلة مقالات الأشعة على أكاديمية حسوب، ومطالعة مقال رياضيات اﻷشعة ضمن توثيق جودو الرسمي. التحويلات في المستوي ثنائي البعد نستخدم في المستوي أو في الفضاء ثنائي البعد اﻹحداثيات المألوفة X و Y، ولنتذكر أنه في محرك ألعاب جودو وفي معظم التطبيقات الرسومية في الحواسيب، يشير المحور Y إلى اﻷسفل كما في الصورة التالية: ولكي نوضح الفكرة، لنتأمل شكل سفينة الفضاء ثنائي البعد في الصورة التالية: تشير السفينة هنا إلى نفس اتجاه المحور X، فلو أردنا منها التحرك نحو اﻷمام، نضيف مقدار الحركة إلى الإحداثي اﻷفقي X فتتحرك نحو اليمين: position += Vector2(10, 0) لكن ما الذي يحدث عندما تدور السفينة؟ كيف يمكن اﻵن تحريكها نحو اﻷمام؟ إن كنتم تتذكرون علم المثلثات في المدرسة، فقد تبدأون بالتفكير في الزوايا والنسب المثلثية sin و cos، ثم تنفذون عملية حسابية مثل: position += Vector2(10 * cos(angle), 10 * sin(angle)) سيعمل هذا الحل، لكن هناك طرق أفضل تلائم عملنا مع اﻷلعاب تعتمد بشكل أساسي على مفهوم التحويلات الهندسية transforms. لنلق نظرة مجددًا على السفينة التي تدور، ولنتخيل هذه المرة أن للسفينة منظومة إحداثياتها الخاصة التي تحملها معها ولا تتعلق بإحداثيات الشاشة العامة: تُخزّن هذه اﻹحداثيات المحلية ضمن الكائن transform، وبالتالي، يمكن تحريك السفينة إلى اﻷمام وفقًا للمحور X الخاص بها ولا حاجة أن نفكر بالزوايا والدوال الرياضية اﻷخرى. ولتنفيذ اﻷمر في جودو، نستخدم الخاصية transform التي تمتلكها جميع العقد المشتقة من Node2D. position += transform.x * 10 ينص السطر السابق على إضافة الشعاع X للتحويل مضروبًا بالعدد 10. لنشرح هذا الأمر بشيء من التفصيل، تضم الخاصية transform اﻹحداثيين x و y الممثلان للإحداثيات المحلية الخاصة بالعقدة، وهما شعاعي واحدة unit vector أي أن طويلة كل منهما تساوي الواحد. كما يطلق على هذان الشعاعان شعاعي توجيه direction vectors، ويدلان على الاتجاه الذي يشير إليه المحور X الخاص بالسفينة. نضرب بعد ذلك شعاعي التوجيه بالعدد 10 لتكبيرهما والانتقال إلى مسافة أبعد. ملاحظة: تتعلق الخاصية transform لعقدة بالعقدة اﻷم لها أي تنسب إحداثياتها الخاصة إلى إحداثيات العقدة اﻷم. فإن أردنا الحصول على اﻹحداثيات العامة بالنسبة إلى الشاشة، نستخدم global_transform. تضم الخاصية transform إضافة إلى المحاور المحلية، مكونًا يُدعى شعاع الأصل origin ويمثل اﻹنسحاب translation أو تغيير الموضع. يمثل الشعاع اﻷزرق في الصور التالية شعاع اﻷصل transform.origin ويساوي شعاع الموضع position للكائن: التحويل بين الفضاء المحلي والعام يمكننا تحويل اﻹحداثيات من الفضاء المحلي للعقدة إلى الفضاء العام عن طريق التحويلات. حيث تضم العقد من النوع Noode2D والنوع Spatial في جودو دوال برمجية مساعدة مثل ()to_local و ()to_global لتحقيق هذا الأمر: var global_position = to_global(local_position) إليكم مثالًا عن كائن في مستوي ثنائي البعد، ونريد تغيير موقع نقرة الفأرة -وهو في الفضاء العام- أي المكان الذي نقرنا فيه على الشاشة، إلى إحداثيات محلية منسوبة إلى الكائن، بمعنى آخر نريد معرفة مكان النقرة من منظور الكائن نفسه بدلاً من المكان على الشاشة، لتحقيق ذلك نكتب الكود التالي: extends Sprite func _unhandled_input(event): if event is InputEventMouseButton and event.pressed: if event.button_index == BUTTON_LEFT: printt(event.position, to_local(event.position)) للمزيد حول آلية التحويل من إحداثيات عامة إلى إحداثيات محلية في محرك جودو ننصحكم بقراءة توثيق Transform2D للاطلاع على كافة الخاصيات والتوابع المتاحة. التحويلات في الفضاء ثلاثي البعد يطبق مفهوم التحويل في الفضاء ثلاثي البعد 3D بنفس أسلوب تطبيقه في الفضاء ثنائي البعد 2D، بل يغدو تطبيقها أهم لأن العمل مع الزوايا في الفضاء ثلاثي البعد سيقود إلى مشكلات عديدة كما سنوضح بعد قليل. ترث العقد ثلاثية البعد من العقدة الأساسية Node3D التي تضم معلومات التحويل. ويحتاج التحويل في الفضاء ثلاثي البعد لمعلومات أكثر مقارنة مع الفضاء الثنائي البعد. حيث يبقى شعاع الموضع Position محفوظًا ضمن الخاصية Origin، لكن الدوران موجود ضمن خاصية تدعى basis تضم ثلاثة أشعة واحدة unit vectors تمثل المحاور اﻹحداثية المحلية الثلاث للعقدة X و Y و Z. وعندما نختار عقدة ثلاثية البعد في محرر جودو، سنتمكن باستخدام نافذة Gizmo من عرض التحويلات والتعامل معها. تفعيل نمط الفضاء المحلي Local Space لنتذكر أن الفضاء العام Global Space هو الفضاء الذي يعتمد على محاور المشهد العامة. بمعنى آخر، إذا كنا نحرك أو ندير كائنًا في هذا الفضاء، فإن تحركاته ستكون بناءً على محاور العالم أو المشهد الذي يوجد فيه هذا الكائن، أما الفضاء المحلي Local Space فهو الفضاء الذي يعتمد على محاور الكائن نفسه. أي أن للكائن لديه محاور خاصة به مثل المحور X و Y و Z الخاص به وعندما نحرك أو ندير الكائن في الفضاء المحلي، فإن تحركاته تكون بالنسبة له هو، وليس بالنسبة للمشهد بأكمله. يتيح لنا محرر جودو عرض الاتجاهات المحلية للجسم والتعامل معها بسهولة، وذلك من خلال تفعيل خيار Local Space Mode، مما يسمح بتحريك الجسم أو تدويره وفقًا لمحاوره الخاصة بدلًا من محاور المشهد العامة، وستمثل المحاور الثلاث الملونة في هذا الوضع المحاور اﻷساسية المحلية للجسم. وكما هو الحال في الفضاء ثنائي البعد، يمكننا في الفضاء ثلاثي الأبعاد استخدام المحاور المحلية لتحريك الجسم إلى اﻷمام. وفي هذه الحالة، يكون المحور Y Y-Upوفق نظام Y-Up أي أنه موجه نحو الأعلى، وبالتالي سيكون الاتجاه الأمامي للجسم بشكل افتراضي هو المحور السالب Z-وبالتالي كي نحرك الجسم للأمام حسب اتجاهه الخاص -وليس حسب اتجاه المشهد- نكتب الكود التالي: position += -transform.basis.z * speed * delta تلميح: يمتلك جودو قيم معرّفة افتراضيًا لبعض الاتجاهات الشائعة، على سبيل المثال يمثل الاختصار Vector3.FORWARD الاتجاه الأمامي في الفضاء ثلاثي الأبعاد: Vector3.FORWARD == Vector3(0, 0, -1) الخاتمة تعلمنا في هذا المقال كيف يمكن لمطور الألعاب أن يتعامل مع التحويلات الهندسية عمليًا داخل محرك ألعاب جودو سواء في المحرك ثنائي البعد 2D أو ثلاثي البعد 3D ويستفيد منها في التحكم بحركة واتجاه العناصر داخل اللعبة من خلال الخصائص المدمجة في المحرك وبعيدًا عن التعقيدات الرياضية مثل الزوايا الدوال المثلثية. ترجمة -وبتصرف- لمقال Transforms اقرأ أيضًا المقال السابق: مفاهيم رياضية أساسية في تطوير اﻷلعاب إعداد منطقة اللعب للعبة ثلاثية الأبعاد باستخدام جودو إنشاء شخصيات ثلاثية الأبعاد في جودو Godot تحريك الشخصية في لعبة 3D باستخدام محرر التحريك في جودو
-
يحتاج أي مطور ألعاب لمعرفة بعض المفاهيم الرياضية الأساسية ليتحكم في انتقال الشخصيات من حالة لأخرى بانسيابية ويتحكم في اتجاهاتها وتمكينها من معرفة ماذا يوجد أمامها وخلفها، وسنناقش في مقال اليوم مفاهيم تستخدم بكثرة في تطوير اﻷلعاب، مثل الاستيفاء الخطي linear interpolation -أو lerp اختصارًا- والجداء الداخلي أو النقطي أو السلمي Dot Product، والجداء الخارجي أو الشعاعي Cross Product. قد تبدو هذه المصطلحات مقعدة وغامضة لمن يسمعها لأول مرة، لكن لا داعي للقلق فبعد قراءة هذا المقال ومعرفة تطبيقاتها العملية في برمجة اﻷلعاب ستغدو سهلة وبسيطة. الاستيفاء العددي تُعطى الصيغة اﻷساسية للاستيفاء الخطي رياضيًا كالتالي: func lerp(a, b, t): return (1 - t) * a + t * b يمثل كل من a و b قيمتين، بينما تمثل t مقدار الاستيفاء بينهما أي النسبة التي تحدد إلى أي درجة ننتقل من a إلى b. وتتراوح قيم t نمطيًا بين 0 وعندها سيعيد الاستيفاء قيمة a، وبين القيمة 1 وعندها سيعيد الاستيفاء قيمة b. يعطي تابع الاستيفاء قيمة ما بين a و b كما في المثال التالي: x = lerp(0, 1, 0.75) # x is 0.75 x = lerp(0, 100, 0.5) # x is 50 x = lerp(10, 75, 0.3) # x is 29.5 x = lerp(30, 2, 0.75) # x is 9 يُدعى هذا الاستيفاء بالاستيفاء الخطي لأنه يعدّ المسافة بين نقطتي الاستيفاء خطًا مستقيمًا. يمكننا تحريك أي خاصية لعقدة ما باستخدام الدالة ()lerp، فلو قسمنا الفترة الزمنية للحركة إلى فترات محددة سنحصل على قيمة بين الصفر والواحد يمكننا استخدامها لتغيير الخاصية المطلوبة بنعومة وسلاسة خلال مدة التنفيذ. كمثال على ذلك، يضاعف السكريبت التالي حجم الشخصية خمس مرات أثناء اختفائها باستخدام modulate.a وتستغرق الحركة ثانيتين: extends Sprite2D var time = 0 var duration = 2 # المدة الزمنية للتأثير func _process(delta): if time < duration: time += delta modulate.a = lerp(1, 0, time / duration) scale = Vector2.ONE * lerp(1, 5, time / duration) الاستيفاء الشعاعي من الممكن الاستيفاء أيضًا بين شعاعين وهذا يعني إيجاد شعاع جديد يقع بينهما بناءً على مقدار معين t تمامًا مثل الاستيفاء بين رقمين، لكن هنا نتعامل مع اتجاهات أو مواقع في الفضاء، إذ توفر كلا العقدتين Vector2 و Vector3 التابع ()linear_interpolate لتنفيذ اﻷمر. فلكي نجد على سبيل المثال شعاعًا يقع في منتصف المسافة بين الشعاع الأمامي واليساري لعقدة من نوع Spatial، نستخدم الاستيفاء الخطي بين هذين الاتجاهين كما في الكود التالي: var forward = -transform.basis.z var left = transform.basis.x var forward_left = forward.linear_interpolate(left, 0.5) كما يحرك المثال التالي الشخصية نحو موقع النقر بالفأرة، وتتحرك العقدة نحو هذا الموقع لكنها لا تقف فجأة حيث تقل سرعة الاقتراب كلما اقترب الكائن أكثر من الهدف: extends Sprite2D var target func _input(event): if event is InputEventMouseButton and event.pressed: target = event.position func _process(delta): if target: position = position.linear_interpolate(target, 0.1) الجداء الشعاعي الداخلي والخارجي يمكن تنفيذ عمليتي جداء على اﻷشعة هما الجداء الداخلي السلمي أو النقطي dot product والذي تكون نتيجته عدد، والجداء الخارجي أو الشعاعي والذي تكون نتيجته شعاعًا. الجداء الداخلي هو عملية حسابية على شعاعين تكون نتيجته عدد حقيقي، وتمثل عادة على أنها مسقط شعاع A على حامل الشعاع اﻵخر B: تُعطى الصيغة الرياضية للجداء الداخلي بالعلاقة: حيث: θ : هي الزاوية بين الشعاعين ||A||: طويلة الشعاع اﻷول ||B||:طويلة الشعاع الثاني ولهذه العلاقة فائدة خاصة عند تسوية الشعاع أي عند جعل طويلته واحد، إذ تصبح العلاقة بالشكل التالي: تشير هذه العلاقة إلى الارتباط المباشر بين الجداء الداخلي والزاوية بين الشعاعين، وطالما أن cos(0)=1 و cos(180)=-1 ستدل قيمة الجداء السلمي على اتجاه الشعاعين بالنسبة لبعضهما، فهما في الزاوية 0 منطبقان وفي الزاوية 180 في اتجاهين مختلفين: وسنرى في فقرة قادمة كيف نستفيد من هذا الجداء عمليًا. الجداء الخارجي ينتح عن الجداء الخارجي لشعاعين شعاع ثالث عمودي على كلا الشعاعين، أي عمود على المستوي الذي يضمهمها، وتتعلق طويلة الشعاع الناتج بطويلتي الشعاعين اﻷصليين والزاوية بينهما. تعطى طويلة الشعاع الناتج عن الجداء الخارجي بالعلاقة: A x B = ||A||.||B||.sin(θ) //هي الزاوية بين الشعاعين θ وإن كانت طويلة كل من الشعاعين هي الواحد ستكون نتيجة الحساب أبسط، إذ تكون طويلة الشعاع الناتج قيمة بين 1- و 1. ملاحظة: طالما أن ناتج الجداء الخارجي بين شعاعين يعطي شعاعًا عموديًا على كلا الشعاعين، فهو عادة ما يُستخدم في المشاهد ثلاثية الأبعاد، حيث أن الشعاع الناتج يكون في اتجاه عمودي على مستوى الفضاء الذي توجد فيه الأشعة الأصلية. من ناحية أخرى، في أطر العمل ثنائية البعد ومن ضمنها جودو، لا يمكن تمثيل الشعاع العمودي داخل نفس المستوى، وبالتالي عند استخدام التابع Vectro2.cross في جودو، فإنه لا يُرجع شعاعًا جديدًا، بل عددًا يمثل طول الشعاع العمودي على الشعاعين في اتجاه الفضاء الثالث أو المحور z وتكون قيمته بين 1- و 1 وتعكس مدى التعامد بين الشعاعين. تطبيقات عملية لنلق نظرة على الصورة المتحركة التالية التي تمثل نتيجة جداء خارجي وداخلي لشعاعين ()Vector2.dot و ()Vector2.cross وكيف تتغير كل نتيجة مع تغير الزاوية بين الشعاعين: توحي هذه الصورة بتطبيقين شائعين لهذين التابعين، فإن كان الشعاع اﻷحمر هو الاتجاه اﻷمامي للكائن وكان اﻷخضر اتجاهًا نحو كائن آخر فسيساعد الجداء الداخلي في معرفة إن كان الكائن الثاني أمامنا -أي عندما تكون قيمة الجداء أكبر من الصفر- أو خلفنا - أي عندما تكون قيمة الجداء أصغر من الصفر. كما يساعد الجداء الخارجي في معرفة إن كان الكائن إلى اليسار -عندما تكون قيمة الجداء أكبر من الصفر- أو إلى اليمين -عندما تكون قيمة الجداء أصغر من الصفر-. الخاتمة تعرفنا في هذا المقال على مفاهيم رياضية أساسية مستخدمة بكثرة في تطوير الألعاب، مثل الاستيفاء الخطي والجداء الداخلي والجداء الخارجي، وتعلمنا كيفية استخدامها للتحكم بحركة شخصيات اللعبة، وتحديد اتجاهاتها، وتحسين تفاعل الكائنات داخل المشهد. من الضروري لأي مطور ألعاب تعلم هذه المفاهيم فهي بمثابة حجر الأساس في تطوير الألعاب وجعل حركة الشخصيات واقعية وسلسة. ترجمة -وبتصرف- للمقالين: Interpolation و Vectors:Using Dot product and Cross product اقرأ أيضًا المقال السابق: سحب وإفلات جسم صلب RigidBody2D في جودو استخدام RigidBody2D في جودو للتوجه نحو هدف والتحرك نحوه تطبيق الجداء النقطي Dot Product على الأشعة في التصاميم 3D تعرف على محرر محرك اﻷلعاب جودو Godot
-
نشرح في هذا المقال طريقة تفاعل شخصية اللاعب في جودو مع الأجسام الصلبة الموجودة في المشهد. ويمكن أن نطبق الطريقة التي سنشرحها على الفضائين الثنائي والثلاثي البعد على حد سواء. التفاعل مع الأجسام الصلبة إذا جربنا استخدام العقدة CharacterBody2D في محرك الألعاب جودو فسنجد أن العقدة CharacterBody2D التي تتحرك افتراضيًا من خلال تنفيذ أحد التابعين ()move_and_slide أو ()move_and_collide تصطدم بالأجسام الصلبة الفيزيائية من حولها لكنها لا تتمكن من دفع أي جسم تتصادم معه مثل صندوق أو عدو، فلن تتفاعل عقدة الجسم الصلب مع عقدة اللاعب إطلاقًا وستسلك سلوك العقدة StaticBody2D. فلو افترضنا أن شخصية اللاعب تمشي وتصطدم بصندوق، عندها ستتوقف أو تغير اتجاهها لكن الصندوق لن يتحرك، قد يكون هذا السلوك في بعض الحالات هو المطلوب فعلًا، لكن إن أردنا أن ندفع هذا الصندوق، سنحتاج لعمل بعض التغييرات. سنستخدم في هذا المثال شخصية ثنائية البعد يمكن تحميلها مع الموارد الأخرى للعبة من هذا المستودع، كما سنستخدم أكثر توابع الحركة شيوعًا لتحريك اللاعب وهو التابع ()move_and_slide الذي يستطيع تحريك الكائنات من نوع CharacterBody2D بشكل آمن ومتوافق مع نظام الفيزياء، حيث يتكفّل بإدارة الاصطدامات والانزلاق على الأسطح والتفاعل مع الجاذبية بشكل تلقائي دون الحاجة لكتابة منطق فيزيائي معقد. سنجد أن أمامنا خيارين لتحديد أسلوب التفاعل مع الأجسام الصلبة فبإمكاننا دفع هذه الأجسام متجاهلين الفيزياء. وهذا الأمر مماثل لخيار العطالة اللانهائية infinite inertia المستخدم في الإصدار 3 من جودو. كما أن بإمكاننا دفع الأجسام بناء على الكتلة المُتخيّلة للشخصية وسرعتها، وسيعطينا ذلك نتيجة واقعية. إذ ستدفع الشخصية الأجسام الثقيلة قليلًا والأجسام الخفيفة كثيرًا، وسنجرب تاليًا كلا الخيارين. العطالة اللانهائية لهذا الخيار إيجابياته وسلبياته. أما الإيجابية الأكبر فهي أنه لا يحتاج إلى شيفرة إضافية. وكل ما علينا هو ضبط طبقات أو أقنعة التصادم collision layers/masks بالشكل الصحيح لكل الأجسام. لتوضيح الأمر، عرّفنا في مثالنا ثلاث طبقات فيزيائية ووضعنا الجسم الصلب ضمن الطبقة رقم 3 وأبقينا على القناع كما هو لتقنيع كل الطبقات: وضعنا بعد ذلك اللاعب في الطبقة الثانية وهي الطبقة player وضبطنا القناع ليتجاهل العناصر الأخرى. عند تشغيل اللعبة، نلاحظ كيف يمكن للاعب دفع الصناديق، ولا يهم في هذه الحالة وزن الصناديق، إذ ستدفع جميعها بنفس المقدار. السلبية التي سنلاحظها في هذا الخيار هو تجاهل فيزياء حركة الصناديق. فبإمكان الصناديق تسلق الجدار، لكن لا يمكن للاعب القفز فوقها. لا بأس بهذا الأمر في بعض الألعاب، لكن إن أردنا منع الجسم من التسلق، علينا الاعتماد على الخيار الثاني. تطبيق الاندفاعات لمنح الجسم المتصادم دفعة لا بد من تطبيق اندفاع impulse، وهو دفعة آنية وكأننا نضرب كرة. وننوه لأن الاندفاع معاكس لمفهوم القوة وهي دفع الجسم باستمرار. # عطالة اللاعب var push_force = 80.0 func _physics_process(delta): # move_and_slide() بعد استدعاء for i in get_slide_collision_count(): var c = get_slide_collision(i) if c.get_collider() is RigidBody2D: c.get_collider().apply_central_impulse(-c.get_normal() * push_force) يتجه ناظم التصادم collision normal خارج الجسم الصلب، لهذا عكسناه ليتجه بعكس اتجاه الشخصية ويُطبّق العامل push_force. وهكذا ستدفع الشخصية الصناديق مجددًا لكنها لن تجبر الصناديق عندما تدفعها نحو الجدار على تسلقه. الخاتمة تعرفنا في هذا المقال على كيفية تفاعل شخصية اللاعب مع الأجسام الصلبة، واستعرضنا طريقتين أساسيتين لتحقيق دفع الشخصية لهذه الأجسام إما بتجاهل الفيزياء باستخدام العطالة اللانهائية، أو بتطبيق الاندفاعات للحصول على سلوك واقعي. يعتمد اختيار الطريقة الأنسب على طبيعة اللعبة والتجربة التي نرغب في تقديمها للاعب لتمنحه سلوكًا منطقيًا. بإمكانك تحميل المشروع كاملًا من مستودعه على جيتهب أو مباشرة من هنا character_vs_rigid.zip. ترجمة -وبتصرف- للمقال: Character to rigid Body interaction اقرأ أيضًا المقال السابق: سحب وإفلات جسم صلب RigidBody2D في جودو استخدام RigidBody2D في جودو للتوجه نحو هدف والتحرك نحوه استخدام الإشارات Signals في جودو Godot إنشاء خرائط مصغرة MiniMap للألعاب في جودو