كتابة محرك لعبة في عامك الأول: سهل! (تقريبيا)

مرحبا! اسمي جليب مارين ، وأنا في السنة الأولى من دراستي الجامعية "الرياضيات التطبيقية وعلوم الكمبيوتر" في سانت بطرسبرغ HSE. في الفصل الثاني ، يقوم جميع الطلاب الجدد في برنامجنا بعمل مشاريع جماعية في C ++. قررت أنا وزملائي كتابة محرك لعبة. 

اقرأ ما نحصل عليه تحت القطة.


هناك ثلاثة منا في الفريق: أنا ، أليكسي لوتشينين وإيليا أونوفريشوك. لا أحد منا خبراء في تطوير الألعاب ، ناهيك عن إنشاء محركات الألعاب. هذا هو أول مشروع كبير بالنسبة لنا: قبله ، قمنا فقط بالواجب المنزلي والعمل المخبري ، لذلك من غير المحتمل أن يجد المحترفون في مجال رسومات الكمبيوتر معلومات جديدة لأنفسهم هنا. سنكون سعداء إذا ساعدت أفكارنا أولئك الذين يريدون أيضًا إنشاء محركهم الخاص. لكن هذا الموضوع معقد ومتعدد الأوجه ، ولا تدعي المقالة بأي حال من الأحوال أنها أدبيات متخصصة كاملة.

أي شخص آخر مهتم بمعرفة تطبيقنا - استمتع بالقراءة!

الفنون التصويرية


النافذة الأولى والماوس ولوحة المفاتيح


لإنشاء النوافذ ، والتعامل مع إدخال الماوس ولوحة المفاتيح ، اخترنا مكتبة SDL2. لقد كان اختيارًا عشوائيًا ، لكن حتى الآن لم نأسف عليه. 

كان من المهم في المرحلة الأولى كتابة غلاف مناسب فوق المكتبة حتى تتمكن من إنشاء نافذة مع سطرين ، والتلاعب بها مثل تحريك المؤشر والدخول إلى وضع ملء الشاشة والتعامل مع الأحداث: ضربات المفاتيح ، وحركات المؤشر. لم تكن المهمة صعبة: لقد أنشأنا بسرعة برنامجًا يمكنه إغلاق النافذة وفتحها ، وعند النقر فوق RMB ، قم بعرض "Hello، World!". 

ثم ظهرت دورة اللعبة الرئيسية:

Event ev;
bool running = true;
while (running):
	ev = pullEvent();
	for handler in handlers[ev.type]:
		handler.handleEvent(ev);

كل معالجات الأحداث المرفقة - handlersمثل handlers[QUIT] = {QuitHandler()}. مهمتهم هي التعامل مع الحدث المقابل. QuitHandlerفي المثال ، سيتم الكشف عنها running = false، وبالتالي إيقاف اللعبة.

مرحبا بالعالم


للرسم في المحرك الذي نستخدمه OpenGL. الأول Hello World، كما أعتقد ، في العديد من المشاريع ، كان مربع أبيض على خلفية سوداء: 

glBegin(GL_QUADS);
glVertex2f(-1.0f, 1.0f);
glVertex2f(1.0f, 1.0f);
glVertex2f(1.0f, -1.0f);
glVertex2f(-1.0f, -1.0f);
glEnd();


ثم تعلمنا كيفية رسم مضلع ثنائي الأبعاد وتنفيذ الأشكال في فصل منفصل GraphicalObject2d، والذي يمكن أن يدور glRotateويتحرك glTranslateويمتد مع glScale. وضعنا اللون في أربع قنوات باستخدام glColor4f(r, g, b, a).

باستخدام هذه الوظيفة ، يمكنك بالفعل إنشاء نافورة جميلة من المربعات. قم بإنشاء فئة ParticleSystemتحتوي على صفيف من الكائنات. في كل تكرار للحلقة الرئيسية ، يقوم نظام الجسيمات بتحديث المربعات القديمة ويجمع بعض المربعات الجديدة التي تبدأ في اتجاه عشوائي:



الة تصوير


كانت الخطوة التالية هي كتابة كاميرا يمكن أن تتحرك وتنظر في اتجاهات مختلفة. لفهم كيفية حل هذه المشكلة ، كنا بحاجة إلى المعرفة من الجبر الخطي. إذا لم يكن هذا مثيرًا للاهتمام بالنسبة لك ، يمكنك تخطي القسم ورؤية gif والقراءة .

نريد رسم قمة في إحداثيات الشاشة ، مع معرفة إحداثياتها بالنسبة لمركز الكائن الذي تنتمي إليه.

  1. أولاً ، نحتاج إلى إيجاد إحداثياته ​​بالنسبة لمركز العالم الذي يوجد فيه الكائن.
  2. بعد ذلك ، لمعرفة إحداثيات وموقع الكاميرا ، ابحث عن موضع الرأس في قاعدة الكاميرا.
  3. ثم قم بإسقاط الرأس على مستوى الشاشة. 

كما ترون ، هناك ثلاث مراحل. الضرب بثلاث مصفوفات يقابلهم. سمينا هذه المصفوفات Model، Viewو Projection.

لنبدأ بالحصول على إحداثيات الكائن في أساس العالم. يمكن إجراء ثلاثة تحويلات باستخدام كائن: التدرج والتدوير والتحريك. يتم تحديد جميع هذه العمليات بضرب المتجه الأصلي (الإحداثيات في أساس الكائن) في المصفوفات المقابلة. ثم Modelستبدو المصفوفة كما يلي: 

Model = Translate * Scale * Rotate. 

علاوة على ذلك ، بمعرفة موضع الكاميرا ، نريد تحديد الإحداثيات في أساسها: اضرب الإحداثيات التي تم الحصول عليها مسبقًا في المصفوفة View. في C ++ ، يتم حساب ذلك بسهولة باستخدام الوظيفة:


glm::mat4 View = glm::lookAt(cameraPosition, objectPosition, up);

حرفيا: انظر objectPositionمن موضع cameraPosition، والاتجاه الصاعد هو "أعلى". لماذا هذا الاتجاه ضروري؟ تخيل تصوير إبريق الشاي. قم بتوجيه الكاميرا إليه ووضع الغلاية في الإطار. عند هذه النقطة ، يمكنك تحديد مكان الإطار بالضبط (على الأرجح حيث يكون للغلاية غطاء). لا يمكن للبرنامج أن يعرف لنا كيفية ترتيب الإطار ، ولهذا السبب يجب تحديد ناقل "أعلى".

حصلنا على الإحداثيات في أساس الكاميرا ، يبقى لإظهار الإحداثيات التي تم الحصول عليها على مستوى الكاميرا. تعمل المصفوفة في هذا Projectionالأمر ، مما يخلق تأثير تقليل الكائن عند إزالته منا.

للحصول على إحداثيات قمة الرأس على الشاشة ، تحتاج إلى ضرب المتجه في المصفوفة خمس مرات على الأقل. جميع المصفوفات لها حجم 4 × 4 ، لذلك عليك القيام ببعض عمليات الضرب. لا نريد تحميل نوى المعالج بالكثير من المهام البسيطة. لهذا ، فإن بطاقة الفيديو التي تحتوي على الموارد اللازمة هي الأفضل. لذا ، تحتاج إلى كتابة تظليل: تعليمات صغيرة لبطاقة الفيديو. OpenGL لديه لغة تظليل GLSL خاصة ، مشابهة لـ C ، والتي ستساعدنا في القيام بذلك. دعونا لا ندخل في تفاصيل كتابة تظليل ، فمن الأفضل أن ننظر أخيراً إلى ما حدث:


التفسير: هناك عشرة مربعات على مسافة قصيرة خلف بعضها البعض. على الجانب الأيمن منهم لاعب يقوم بتدوير وتحريك الكاميرا. 

الفيزياء


ما هي لعبة بدون فيزياء؟ للتعامل مع التفاعل المادي ، قررنا استخدام مكتبة Box2d وأنشأنا فصلًا WorldObject2dورثته GraphicalObject2d. لسوء الحظ ، لم يعمل Box2d خارج الصندوق ، لذلك كتب إيليا الشجاع غلافًا لـ b2Body وجميع الاتصالات المادية الموجودة في هذه المكتبة.


حتى تلك اللحظة ، فكرنا في جعل الرسومات في المحرك ثنائية الأبعاد تمامًا ، وللإضاءة ، إذا قررنا إضافتها ، استخدم تقنية البث الشعاعي. ولكن كان لدينا كاميرا رائعة يمكنها عرض الأشياء في جميع الأبعاد الثلاثة. لذلك ، أضفنا سمكًا إلى جميع الكائنات ثنائية الأبعاد - لماذا لا؟ بالإضافة إلى ذلك ، في المستقبل ، سيسمح لك ذلك بعمل إضاءة جميلة جدًا تترك الظلال من الأشياء السميكة.

ظهرت الإضاءة بين الحالات. لإنشائه ، كان من الضروري كتابة التعليمات المناسبة لرسم كل بكسل - تظليل جزء.



القوام


استخدمنا مكتبة DevIL لتحميل الصور. GraphicalObject2dأصبح كل منها مناسبًا لمثيل واحد من الفصل GraphicalPolygon- الجزء الأمامي من الكائن - GraphicalEdgeوالجزء الجانبي. على كل يمكنك تمدد الملمس الخاص بك. النتيجة الأولى:


كل ما هو مطلوب من الرسومات جاهز: الرسم ومصدر ضوء واحد وملمس. الرسومات - هذا كل شيء الآن.

آلة الدولة ، تحديد سلوك الأشياء


يجب أن يكون كل شيء ، مهما كان - حالة في جهاز الحالة ، أو رسم بياني أو مادي - "موقوتًا" ، أي أنه يتم تحديث كل تكرار لحلقة اللعبة.

الكائنات التي يمكن تحديثها موروثة من فئة السلوك التي أنشأناها. لديها وظائف onStart, onActive, onStopتسمح لك بتجاوز سلوك الوريث عند بدء التشغيل وأثناء الحياة وفي نهاية نشاطه. الآن نحن بحاجة إلى إنشاء كائن أعلى Activityيستدعي هذه الوظائف من جميع الكائنات. وظيفة الحلقة التي تفعل ذلك هي كما يلي:

void loop():
    onAwake();
    awake = true;
    while (awake):
        onStart();
        running = true
        while (running):
            onActive();
        onStop();
    onDestroy();

في الوقت الحالي running == true، يمكن لشخص ما استدعاء وظيفة pause()تقوم بذلك running = false. إذا اتصل شخص ما kill()، ثم awake، runningوالتوجه إلى false، والنشاط إلى توقف كامل.

المشكلة: نريد إيقاف مجموعة من الأشياء ، على سبيل المثال ، نظام من الجسيمات والجزيئات داخلها. في الحالة الحالية ، تحتاج إلى الاتصال يدويًا onPauseبكل كائن ، وهو أمر غير مريح للغاية.

الحل:Behavior سيكون لدى الجميع مصفوفة subBehaviorsسيقوم بتحديثها ، وهي:

void onStart():
	onStart() 		//     
	for sb in subBehaviors:
		sb.onStart()	//       Behavior
void onActive():
	onActive()
	for sb in subBehaviors:
		sb.onActive()

وهكذا ، لكل وظيفة.

ولكن لا يمكن ضبط كل سلوك بهذه الطريقة. على سبيل المثال ، إذا كان العدو يسير على منصة - العدو ، فعلى الأرجح لديه دول مختلفة: إنه يقف - idle_stay، إنه يسير على منصة دون أن يلاحظنا - idle_walkوفي أي لحظة يمكنه أن يلاحظنا ويدخل في حالة هجوم - attack. أرغب أيضًا في تعيين شروط الانتقال بين الدول بسهولة ، على سبيل المثال:

bool isTransitionActivated(): 		//  idle_walk->attack
	return canSee(enemy);

النمط المطلوب هو آلة حالة. لقد جعلناها وريثة أيضًا Behavior، حيث أنه من الضروري في كل علامة تحديد ما إذا كان الوقت قد حان لتغيير الحالة. هذا مفيد ليس فقط للكائنات في اللعبة. على سبيل المثال ، Levelهذه حالة Level Switcher، والانتقالات داخل آلة التحكم هي شروط لتبديل المستويات في اللعبة.

للدولة ثلاث مراحل: لقد بدأت ، تدق ، توقفت. يمكنك إضافة بعض الإجراءات إلى كل مرحلة ، على سبيل المثال ، إرفاق نسيج إلى كائن ، وتطبيق دفعة عليه ، وتعيين السرعة ، وما إلى ذلك.

الحفاظ على


عند إنشاء مستوى في المحرر ، أريد أن أتمكن من حفظه ، ويجب أن تكون اللعبة نفسها قادرة على تحميل المستوى من البيانات المحفوظة. لذلك ، كل الكائنات التي تحتاج إلى حفظها موروثة من الفئة NamedStoredObject. يقوم بتخزين سلسلة باسم واسم الفئة وله وظيفة dump()تفريغ البيانات حول كائن في سلسلة.  

لإجراء الحفظ ، يبقى تجاوز dump()كل كائن ببساطة . التحميل عبارة عن مُنشئ من سلسلة تحتوي على كافة المعلومات حول الكائن. يكتمل التنزيل عند إنشاء مُنشئ لكل كائن. 

في الواقع ، اللعبة والمحرر تقريبًا من نفس الفئة ، فقط في اللعبة يتم تحميل المستوى في وضع القراءة ، وفي المحرر في وضع التسجيل. يستخدم المحرك مكتبة fastjson لكتابة وقراءة الكائنات من json.

واجهة المستخدم الرسومية


في مرحلة ما ، نشأ السؤال أمامنا: فليكتب الرسومات ، وآلة الحالة ، وكل ما تبقى. كيف يمكن للمستخدم كتابة لعبة باستخدام هذا؟ 

في النسخة الأصلية ، كان عليه أن يرث من Game2dويحل onActive، وأن ينشئ كائنات في حقول الصف. ولكن أثناء الإنشاء ، لا يمكنه رؤية ما يقوم بإنشائه ، وسيحتاج أيضًا إلى تجميع برنامجه وارتباطه بمكتبتنا. رعب! سيكون هناك إيجابيات - يمكن للمرء أن يسأل عن مثل هذه السلوكيات المعقدة التي يمكن للمرء أن يتخيلها: على سبيل المثال ، نقل كتلة الأرض مثل حياة اللاعب ، والقيام بذلك شريطة أن يكون أورانوس في كوكبة برج الثور ولا يتجاوز اليورو 40 روبل. ومع ذلك ، قررنا إنشاء واجهة رسومية.

في الواجهة الرسومية ، سيكون عدد الإجراءات التي يمكن إجراؤها باستخدام كائن محدودًا: التقليب عبر شريحة الرسوم المتحركة ، وتطبيق القوة ، وتعيين سرعة معينة ، وما إلى ذلك. نفس الوضع مع التحولات في آلة الدولة. في المحركات الكبيرة ، يتم حل مشكلة عدد محدود من الإجراءات من خلال ربط البرنامج الحالي ببرنامج آخر - على سبيل المثال ، استخدام Unity و Godot باستخدام C #. بالفعل من هذا البرنامج النصي ، يمكنك القيام بأي شيء: ومعرفة أي كوكبة أورانوس ، وما هو سعر صرف اليورو الحالي. ليس لدينا مثل هذه الوظائف في الوقت الحالي ، ولكن تتضمن خططنا توصيل المحرك بـ Python 3.

لتنفيذ الواجهة الرسومية ، قررنا استخدام عزيزي ImGui ، لأنه صغير جدًا (مقارنة بـ Qt المعروف) والكتابة عليه بسيطة للغاية. ImGui - نموذج إنشاء واجهة رسومية. في ذلك ، كل تكرار للحلقة الرئيسية ، يتم إعادة رسم جميع الحاجيات والنوافذ فقط إذا لزم الأمر. من ناحية ، يقلل هذا من مقدار الذاكرة المستهلكة ، ولكن من ناحية أخرى ، يستغرق الأمر على الأرجح أكثر من تنفيذ واحد للوظيفة المعقدة لإنشاء وحفظ المعلومات الضرورية للرسم اللاحق. يبقى فقط لتنفيذ واجهات إنشاء وتحرير.

إليك كيف تبدو واجهة المستخدم الرسومية في وقت إصدار المقالة:


محرر المستوى


محرر آلة الدولة

استنتاج


لقد أنشأنا فقط الأساس الذي يمكنك من خلاله تعليق شيء أكثر إثارة للاهتمام. بعبارة أخرى ، هناك مجال للنمو: يمكنك تنفيذ تجسيد الظل ، والقدرة على إنشاء أكثر من مصدر ضوئي واحد ، ويمكنك توصيل المحرك بمترجم Python 3 لكتابة النصوص البرمجية للعبة. أود تحسين الواجهة: اجعلها أكثر جمالًا ، وأضف المزيد من العناصر المختلفة ، ودعم المفاتيح الساخنة ...

لا يزال هناك الكثير من العمل ، لكننا سعداء بما لدينا في الوقت الحالي. 

خلال إنشاء المشروع ، حصلنا على الكثير من الخبرات المتنوعة: العمل مع الرسومات ، وإنشاء واجهات رسومية ، والعمل مع ملفات json ، مغلفة للعديد من مكتبات C. وكذلك تجربة كتابة أول مشروع كبير في فريق. نأمل أن نكون قد تمكنا من إخبارنا عن الأمر المثير للاهتمام حيث كان من المثير للاهتمام التعامل معه :)

رابط لهذا المشروع gihab: github.com/Glebanister/ample

All Articles