خلق roguelike في الوحدة من الصفر

صورة

لا توجد العديد من البرامج التعليمية حول إنشاء لعبة roguelike في Unity ، لذلك قررت كتابتها. ليس للتفاخر ، ولكن لتبادل المعرفة مع أولئك الذين هم في المرحلة التي كنت فيها بالفعل فترة طويلة.

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

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

مجتمعات

Discord الرائعة واطلب المساعدة: Unity Developer Community Roguelikes

لذا ، فلنبدأ!

المرحلة 0 - التخطيط


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

سنكتب roguelike. سنتبع بشكل رئيسي النصيحة الحكيمة لمطور Cogmind Josh Ge هنا . اتبع الرابط ، اقرأ المنشور أو شاهد الفيديو ، ثم ارجع.

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

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

لن أعطيك أي موارد رسومية. يوجه لهم نفسك أو استخدام tilesets الحرة، والتي يمكن تحميلها هنا ، هنا أو من خلال البحث في جوجل. فقط لا تنسى أن تذكر مؤلفي الرسومات في اللعبة.

الآن دعونا نذكر جميع الوظائف التي ستكون في روجلوك لدينا في ترتيب تنفيذها:

  1. إنشاء خريطة المحصنة
  2. شخصية اللاعب وحركته
  3. مجال الرؤية
  4. الأعداء
  5. ابحث عن طريقة
  6. القتال والصحة والموت
  7. مستوى اللاعب أعلى
  8. الأصناف (الأسلحة والجرعات)
  9. غش وحدة التحكم (للاختبار)
  10. أرضيات زنزانة
  11. حفظ وتحميل
  12. الرئيس النهائي

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

المرحلة 1 - فئة MapManager


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

لذا ، قم بإنشاء برنامج نصي cs. يسمى MapManager وافتحه.

حذف ": MonoBehaviour" لأنه لن يرث منه ولن يتم إرفاقه بأي GameObject.

قم بإزالة الدالتين Start () و Update ().

في نهاية فئة MapManager ، قم بإنشاء فئة عامة جديدة تسمى Tile.


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


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


هاهو! لدينا خريطة!

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

يبدو الرمز الناتج كما يلي:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class MapManager 
{
    public static Tile[,] map; // the 2-dimensional map with the information for all the tiles
}

public class Tile { //Holds all the information for each tile on the map
    public int xPosition; // the position on the x axis
    public int yPosition; // the position on the y axis
    public GameObject baseObject; // the map game object attached to that position: a floor, a wall, etc.
}

تم الانتهاء من المرحلة الأولى ، دعنا ننتقل لملء البطاقة. الآن سنبدأ في إنشاء مولد زنزانة.

المرحلة 2 - بضع كلمات حول بنية البيانات


ولكن قبل أن تبدأ ، اسمحوا لي أن أشارك النصائح التي نشأت بفضل التعليقات الواردة بعد نشر الجزء الأول. عند إنشاء بنية بيانات ، تحتاج إلى التفكير منذ البداية في كيفية الحفاظ على حالة اللعبة. خلاف ذلك ، في وقت لاحق سيكون أكثر فوضوية. قال مستخدم Discord st33d ، مطور Star Shaped Bagel (يمكنك لعب هذه اللعبة مجانًا هنا ) أنه أنشأ اللعبة في البداية ، معتقدًا أنها لن تنقذ الولايات على الإطلاق. بدأت اللعبة بالتدريج بالتزايد ، وطلب معجبها دعم الخريطة المحفوظة. ولكن بسبب الطريقة المختارة لإنشاء بنية البيانات ، كان من الصعب جدًا حفظ البيانات ، لذلك لم يكن قادرًا على القيام بذلك.


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

سنحفظ أشياء مثل مجموعة من المتغيرات من فئة البلاط التي يتم تخزين الخريطة فيها. سنحفظ كل هذه البيانات ، باستثناء متغيرات فئة GameObject ، الموجودة داخل فئة Tile. لماذا ا؟ فقط لأنه لا يمكن إجراء تسلسل لـ GameObjects مع Unity على البيانات المخزنة.

لذلك ، في الواقع ، لا نحتاج إلى حفظ البيانات المخزنة داخل GameObjects. سيتم تخزين جميع البيانات في فصول مثل Tile ، وكذلك لاحقًا Player و Enemy وما إلى ذلك. ثم سيكون لدينا GameObjects لتبسيط حساب أشياء مثل الرؤية والحركة ، وكذلك رسم النقوش المتحركة على الشاشة. لذلك ، سيكون هناك متغيرات GameObject داخل الفئات ، ولكن لن يتم حفظ قيمة هذه المتغيرات وتحميلها. عند التحميل ، سنضطر إلى إنشاء GameObject مرة أخرى من البيانات المحفوظة (الموضع ، الرموز المتحركة ، إلخ).

ثم ماذا علينا أن نفعل الآن؟ حسنًا ، ما عليك سوى إضافة سطرين إلى فئة Tile الموجودة وأحدهما إلى أعلى النص. أولاً نضيف "باستخدام النظام" ؛ إلى عنوان البرنامج النصي ، ثم [Serializable] أمام الفصل بأكمله و [NonSerialized] أمام متغير GameObject. مثله:



سأخبرك بالمزيد حول هذا عندما نصل إلى جزء البرنامج التعليمي حول الحفظ / التحميل. في الوقت الحالي ، دعنا نترك كل شيء وننتقل.

المرحلة 3 - المزيد حول بنية البيانات


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

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

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

بالإضافة إلى ذلك ، إذا كنت ترغب في قراءة مناقشة حول هذا الموضوع ، فيمكن القيام بذلك على Reddit .

المرحلة 4 - خوارزمية جيل الزنزانة


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

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

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

لذا ، دعنا نصف هذا بطريقة تشبه الخوارزمية ، خطوة بخطوة. من أجل الراحة ، سأطلق على كل تفاصيل الخريطة (الممر أو الغرفة) عنصرًا حتى لا أضطر إلى قول "غرفة / ممر" في كل مرة.

  1. اقطع الغرفة في وسط الخريطة
  2. حدد أحد الجدران بشكل عشوائي
  3. اخترقنا الممر في هذا الجدار
  4. حدد أحد العناصر الموجودة عشوائيًا.
  5. حدد أحد جدران هذا العنصر بشكل عشوائي
  6. إذا كان العنصر الأخير المحدد عبارة عن غرفة ، فإننا ننشئ ممرًا. إذا كان الممر ، فاختر عشوائيًا ما إذا كان العنصر التالي سيكون غرفة أو ممر آخر
  7. تحقق مما إذا كانت هناك مساحة كافية في الاتجاه المحدد لإنشاء العنصر المطلوب
  8. إذا كان هناك مكان ، فقم بإنشاء عنصر ، إذا لم يكن كذلك ، فارجع إلى الخطوة 4
  9. كرر من الخطوة 4

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

المرحلة 5 - قطع الغرفة


أخيرا انتقل إلى الترميز! دعنا نقطع غرفتنا الأولى.

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


بعد ذلك نحتاج إلى تهيئة مولد الزنزانة. نقوم بذلك لتهيئة المتغيرات التي سيتم ملؤها بالجيل. في الوقت الحالي ، ستكون هذه مجرد خريطة. وكذلك حذف الدالتين Start () و Update () التي تنشئها Unity للبرنامج النصي الجديد ، لن نحتاج إليها.



هنا قمنا بتهيئة متغير الخريطة لفئة MapManager (التي أنشأناها في الخطوة السابقة) ، لتمرير عرض الخريطة وارتفاعها ، المحدد بواسطة المتغيرات أعلاه كمعلمات لبُعدَي الصفيف. وبفضل هذا ، سيكون لدينا خريطة بحجم x أفقي (عرض) وحجم y رأسي (ارتفاع) ، ويمكننا الوصول إلى أي خلية في الخريطة عن طريق إدخال MapManager.map [x، y]. سيكون هذا مفيدًا جدًا عند معالجة موضع الأشياء.

الآن سنقوم بإنشاء وظيفة لتقديم الغرفة الأولى. سوف نسميها FirstRoom (). لقد جعلنا InitializeDungeon () وظيفة عامة ، لأنه سيتم إطلاقها بواسطة نص برمجي آخر (Game Manager ، الذي سنقوم بإنشائه قريبًا ؛ وسوف يركز إدارة عملية إطلاق اللعبة بالكامل). لا نحتاج إلى أي نصوص خارجية للوصول إلى FirstRoom () ، لذلك لا نجعلها عامة.

الآن ، للمتابعة ، سننشئ ثلاث فئات جديدة في البرنامج النصي MapManager حتى تتمكن من إنشاء غرفة. هذه هي فئات الميزة والجدار والموضع. ستحتوي فئة الموضع على موضع x و y حتى نتمكن من تتبع مكان كل شيء. سيحتوي الجدار على قائمة بالمواقع ، والاتجاه الذي "يبدو" بالنسبة لمركز الغرفة (الشمال ، الجنوب ، الشرق أو الغرب) ، الطول ، ووجود عنصر جديد تم إنشاؤه منه. سيحتوي العنصر على قائمة بجميع المواضع التي يتكون منها ، ونوع العنصر (الغرفة أو الممر) ، ومجموعة من متغيرات الجدار ، وعرضه وارتفاعه.



الآن دعنا نصل إلى وظيفة FirstRoom (). دعنا نعود إلى البرنامج النصي DungeonGenerator وننشئ وظيفة أسفل InitializeDungeon مباشرة. لن تحتاج إلى تلقي أي معلمات ، لذلك سنتركها بسيطة (). بعد ذلك ، داخل الوظيفة ، نحتاج أولاً إلى إنشاء وتهيئة متغير الغرفة وقائمة متغيرات الموضع الخاصة به. نفعل ذلك على هذا النحو:


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


بعد ذلك ، نحتاج إلى الإعلان عن مكان نقطة البداية للغرفة ، أي حيث سيتم وضع نقطة الغرفة 0.0 في شبكة الخريطة. نريد أن نبدأ في وسط الخريطة (نصف عرض ونصف ارتفاع) ، ولكن ربما ليس بالضبط في المركز. قد يكون من المفيد إضافة أداة عشوائية صغيرة بحيث تتحرك قليلاً إلى اليسار والأسفل. لذلك ، قمنا بتعيين xStartingPoint على شكل نصف عرض الخريطة ، و yStartingPoint على أنها نصف ارتفاع الخريطة ، ثم نأخذ roomWidth و roomHeight المحددين ، نحصل على قيمة عشوائية من 0 إلى هذا العرض / الارتفاع ، ونطرحها من x و y الأوليين. مثله:



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

بعد تهيئة المصفوفة ، نقوم بالدوران حول كل عنصر من عناصر المصفوفة في الحلقة () ، وتهيئة متغيرات كل جدار ، ثم استخدام المفتاح ، الذي يسمي اتجاه كل جدار. يتم اختياره بشكل تعسفي ، نحتاج فقط إلى تذكر ما سيعنيه.


الآن سنقوم بتنفيذ اثنين من الحلقات المتداخلة فورًا بعد وضع الجدران. في الحلقة الخارجية ، نتجول حول جميع قيم y في الغرفة ، وفي الحلقة المتداخلة ، جميع قيم x. بهذه الطريقة سوف نتحقق من كل خلية x في الصف y حتى نتمكن من تنفيذها.


ثم أول شيء يجب القيام به هو العثور على القيمة الحقيقية لموضع الخلية على مقياس الخريطة من موضع الغرفة. هذا بسيط جدًا: لدينا نقطتي البداية x و y. سيكونون الموضع 0،0 في شبكة الغرفة. ثم إذا احتجنا للحصول على القيمة الحقيقية لـ x ، y من أي x ، y ، فإننا نضيف x و y المحليين مع المواضع الأولية x و y. ثم نقوم بحفظ قيم x و y الحقيقية في متغير الموضع (من فصل تم إنشاؤه سابقًا) ، ثم نضيفها إلى القائمة <> لمواقع الغرفة.


الخطوة التالية هي إضافة هذه المعلومات إلى الخريطة. قبل تغيير القيم ، تذكر تهيئة المتغير Tile.


الآن سنقوم بتغيير فئة البلاط. دعنا نذهب إلى البرنامج النصي MapManager ونضيف سطرًا واحدًا لتعريف فئة Tile: "public string type؛". سيسمح لنا هذا بإضافة فئة تجانب من خلال التصريح بأن التجانب في x أو y هو جدار أو أرضية أو أي شيء آخر. بعد ذلك ، دعنا نعود إلى الدورة التي قمنا فيها بالعمل وإضافة هيكل كبير إذا كان آخر ، والذي سيسمح لنا ليس فقط بتحديد كل جدار وطوله وجميع المواضع في هذا الجدار ، ولكن أيضًا لتحديد على الخريطة العالمية ما هو بلاط معين - جدار أو الجنس.


وقد قمنا بالفعل بشيء ما. إذا كان المتغير y (التحكم في المتغير في الحلقة الخارجية) يساوي 0 ، فإن البلاط ينتمي إلى أدنى صف من الخلايا في الغرفة ، أي أنه الجدار الجنوبي. إذا كانت x (التحكم في متغير الحلقة الداخلية) تساوي 0 ، فإن البلاط ينتمي إلى العمود الأيسر من الخلايا ، أي أنه الجدار الغربي. وإذا كان على الخط العلوي ، فإنه ينتمي إلى الجدار الشمالي ، وفي أقصى اليمين - الجدار الشرقي. نطرح 1 من المتغيرات roomWidth و roomHeight ، لأنه تم حساب هذه القيم بدءًا من 1 ، وبدأت متغيرات x و y للدورة من 0 ، لذا نحتاج إلى أخذ هذا الاختلاف في الاعتبار. وجميع الخلايا التي لا تستوفي الشروط ليست جدرانًا ، أي أنها أرضية.


رائع ، لقد انتهينا تقريبًا من الغرفة الأولى. إنه جاهز تقريبًا ، نحتاج فقط إلى وضع القيم الأخيرة في الميزة المتغيرة التي أنشأناها. نخرج من الحلقة وننهي الوظيفة على النحو التالي:


غرامة! لدينا غرفة!

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

المرحلة 6 - رسم الغرفة الأولى


أول شيء يجب التفكير فيه عند تنفيذ بطاقة ASCII هو الخط الذي يجب اختياره. العامل الرئيسي الذي يجب مراعاته عند اختيار خط لـ ASCII هو ما إذا كان متناسبًا (عرض متغير) أو أحادي المسافة (عرض ثابت). نحتاج إلى خط أحادي المسافة بحيث تبدو البطاقات حسب الحاجة (انظر المثال أدناه). بشكل افتراضي ، يستخدم أي مشروع Unity جديد الخط Arial ، وهو ليس أحادي المسافة ، لذا نحتاج إلى البحث عن آخر. يحتوي Windows 10 عادةً على خطوط أحادية المسافة Courier New و Consolas و Lucida Console. اختر واحدًا من هؤلاء الثلاثة أو قم بتنزيل أي مكان آخر في المكان الذي تريده وضعه في مجلد الخطوط داخل مجلد الأصول بالمشروع.


دعونا نجهز المشهد لإخراج ASCII. بالنسبة للمبتدئين ، اجعل لون خلفية الكاميرا الرئيسية للمشهد أسود. ثم نضيف كائن Canvas إلى المشهد ، ونضيف كائن النص إليه. اضبط تحويل مستطيل النص إلى المركز الأوسط وإلى الموضع 0،0،0. قم بتعيين كائن النص بحيث يستخدم الخط الذي تختاره واللون الأبيض والفائض الرأسي والأفقي (تجاوز أفقي / رأسي) ، وحدد تجاوز التدفق ، وقم بتوسيط المحاذاة الرأسية والأفقية. ثم أعد تسمية كائن النص إلى "ASCIITest" أو ما شابه.

الآن عد إلى الكود. في البرنامج النصي DungeonGenerator ، قم بإنشاء وظيفة جديدة تسمى DrawMap. نريدها أن تحصل على معلمة تخبر أي بطاقة تنشئها - ASCII أو sprite ، لذا قم بإنشاء معلمة منطقية واسميها isCII.


ثم سنتحقق مما إذا كانت الخريطة المقدمة هي ASCII. إذا كانت الإجابة بنعم (في الوقت الحالي ، سننظر في هذه الحالة فقط) ، فسنبحث عن كائن نصي في المشهد ، ونمرر الاسم الممنوح له كمعلمة ، ونحصل على مكون النص الخاص به. ولكن أولاً ، نحتاج إلى إخبار Unity أننا نريد العمل مع واجهة المستخدم. أضف السطر باستخدام UnityEngine.UI إلى رأس البرنامج النصي:


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


غرامة. لذلك ، في كل مرة يتم استدعاء DrawMap ، سنحتاج إلى إبلاغ ما إذا كانت البطاقة هي ASCII. إذا كان الأمر كذلك (وسنعمل دائمًا على هذه الطريقة ، فسنعمل معًا في وقت لاحق) ، فستقوم الوظيفة بالبحث في التسلسل الهرمي للمشهد بحثًا عن كائن لعبة يسمى "ASCIITest". إذا كان كذلك ، فسيستقبل مكون النص الخاص به ويحفظه في متغير الشاشة ، والذي يمكننا بعد ذلك كتابة الخريطة بسهولة. ثم ينشئ سلسلة فارغة قيمتها في البداية. سوف نملأ هذا الخط بخريطة تحمل علامات.

عادة ننتقل حول الخريطة في حلقة ، بدءًا من 0 وحتى نهاية طولها. ولكن لملء السطر ، نبدأ بالسطر الأول من النص ، أي الأعلى. لذلك ، على المحور y ، نحتاج إلى التحرك في حلقة في الاتجاه المعاكس ، والانتقال من نهاية إلى بداية الصفيف. لكن المحور السيني للصفيف ينتقل من اليسار إلى اليمين ، تمامًا مثل النص ، لذلك هذا يناسبنا.


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


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


بالإضافة إلى ذلك ، نحتاج إلى إجراء فواصل أسطر عند الوصول إلى نهاية صف المصفوفة ، بحيث تكون الخلايا التي لها نفس موضع x مباشرة تحت بعضها البعض. سوف نتحقق عند كل تكرار للحلقة مما إذا كانت الخلية هي الأخيرة في الصف ، ثم نضيف فاصل أسطر بالحرف الخاص "\ n".


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



تهانينا! لقد أكملت البرنامج النصي الذي ينشئ الغرفة ويعرضها على الشاشة. الآن نحن بحاجة فقط لوضع هذه الخطوط موضع التنفيذ. لا نستخدم Start () في برنامج DungeonGenerator النصي ، لأننا نريد أن يكون لدينا نص منفصل للتحكم في كل شيء يتم تنفيذه في بداية اللعبة ، بما في ذلك إنشاء الخريطة ، ولكن أيضًا إعداد اللاعب والأعداء وما إلى ذلك. لذلك ، سيحتوي هذا البرنامج النصي الآخر على وظيفة Start () ، وإذا لزم الأمر ، سيتم استدعاء وظائف البرنامج النصي الخاص بنا. يحتوي البرنامج النصي DungeonGenerator على دالة تهيئة ، وهي عامة ، و FirstRoom و DrawMap ليست عامة. تهيئة تهيئة ببساطة المتغيرات لتخصيص عملية إنشاء الزنزانة ، لذلك نحن بحاجة إلى وظيفة أخرى تستدعي عملية الإنشاء ، والتي يجب أن تكون عامة حتى يمكن استدعاؤها من البرامج النصية الأخرى.في الوقت الحالي ، ستستدعي فقط وظيفة FirstRoom () ، ثم وظيفة DrawMap () ، لتمريرها قيمة حقيقية بحيث ترسم خريطة ASCII. أوه ، أو لا ، إنه أفضل - دعنا ننشئ متغيرًا عامًا هو ASCII ، والذي يمكن تضمينه في المفتش ، ونمر هذا المتغير كمعلمة للدالة. غرامة.


لذا ، فلنقم الآن بإنشاء برنامج نصي GameManager. سيكون البرنامج النصي هو الذي يتحكم في جميع العناصر عالية المستوى في اللعبة ، على سبيل المثال ، إنشاء خريطة ومسار التحركات. دعنا نزيل وظيفة Update () فيها ، ونضيف متغيرًا من نوع DungeonGenerator يسمى dungeonGenerator ، وننشئ مثيلًا لهذا المتغير في دالة Start ().


بعد ذلك ، نسمي ببساطة الدالتين InitializeDungeon () و GenerateDungeon () من dungeonGenerator ، بهذا الترتيب . هذا أمر مهم - تحتاج أولاً إلى تهيئة المتغيرات ، وبعد ذلك فقط تبدأ في البناء على أساسها.


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


الآن فقط اضغط على تشغيل ومشاهدة السحر! من المفترض أن ترى شيئًا مشابهًا على شاشة اللعبة:


تهانينا ، لدينا الآن غرفة!

أردت أن نضع شخصية اللاعب هناك ونجعله يتحرك ، لكن المنشور كان طويلًا بالفعل. لذلك ، في الجزء التالي ، يمكننا المضي قدمًا مباشرةً في تنفيذ بقية خوارزمية الزنزانة ، أو يمكننا وضع اللاعب فيها وتعليمها كيفية التحرك. صوّت لما تفضله في التعليقات على المقال الأصلي.

MapManager.cs:

using System.Collections;
using System; // So the script can use the serialization commands
using System.Collections.Generic;
using UnityEngine;

public class MapManager {
    public static Tile[,] map; // the 2-dimensional map with the information for all the tiles
}

[Serializable] // Makes the class serializable so it can be saved out to a file
public class Tile { // Holds all the information for each tile on the map
    public int xPosition; // the position on the x axis
    public int yPosition; // the position on the y axis
    [NonSerialized]
    public GameObject baseObject; // the map game object attached to that position: a floor, a wall, etc.
    public string type; // The type of the tile, if it is wall, floor, etc
}

[Serializable]
public class Position { //A class that saves the position of any cell
    public int x;
    public int y;
}

[Serializable]
public class Wall { // A class for saving the wall information, for the dungeon generation algorithm
    public List<Position> positions;
    public string direction;
    public int length;
    public bool hasFeature = false;
}

[Serializable]
public class Feature { // A class for saving the feature (corridor or room) information, for the dungeon generation algorithm
    public List<Position> positions;
    public Wall[] walls;
    public string type;
    public int width;
    public int height;
}

DungeonGenerator.cs:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

public class DungeonGenerator : MonoBehaviour
{
    public int mapWidth;
    public int mapHeight;

    public int widthMinRoom;
    public int widthMaxRoom;
    public int heightMinRoom;
    public int heightMaxRoom;

    public int maxCorridorLength;
    public int maxFeatures;

    public bool isASCII;

    public void InitializeDungeon() {
        MapManager.map = new Tile[mapWidth, mapHeight];
    }

    public void GenerateDungeon() {
        FirstRoom();
        DrawMap(isASCII);
    }

    void FirstRoom() {
        Feature room = new Feature();
        room.positions = new List<Position>();

        int roomWidth = Random.Range(widthMinRoom, widthMaxRoom);
        int roomHeight = Random.Range(heightMinRoom, heightMaxRoom);

        int xStartingPoint = mapWidth / 2;
        int yStartingPoint = mapHeight / 2;

        xStartingPoint -= Random.Range(0, roomWidth);
        yStartingPoint -= Random.Range(0, roomHeight);

        room.walls = new Wall[4];

        for (int i = 0; i < room.walls.Length; i++) {
            room.walls[i] = new Wall();
            room.walls[i].positions = new List<Position>();
            room.walls[i].length = 0;

            switch (i) {
                case 0:
                    room.walls[i].direction = "South";
                    break;
                case 1:
                    room.walls[i].direction = "North";
                    break;
                case 2:
                    room.walls[i].direction = "West";
                    break;
                case 3:
                    room.walls[i].direction = "East";
                    break;
            }
        }

        for (int y = 0; y < roomHeight; y++) {
            for (int x = 0; x < roomWidth; x++) {
                Position position = new Position();
                position.x = xStartingPoint + x;
                position.y = yStartingPoint + y;

                room.positions.Add(position);

                MapManager.map[position.x, position.y] = new Tile();
                MapManager.map[position.x, position.y].xPosition = position.x;
                MapManager.map[position.x, position.y].yPosition = position.y;

                if (y == 0) {
                    room.walls[0].positions.Add(position);
                    room.walls[0].length++;
                    MapManager.map[position.x, position.y].type = "Wall";
                }
                else if (y == (roomHeight - 1)) {
                    room.walls[1].positions.Add(position);
                    room.walls[1].length++;
                    MapManager.map[position.x, position.y].type = "Wall";
                }
                else if (x == 0) {
                    room.walls[2].positions.Add(position);
                    room.walls[2].length++;
                    MapManager.map[position.x, position.y].type = "Wall";
                }
                else if (x == (roomWidth - 1)) {
                    room.walls[3].positions.Add(position);
                    room.walls[3].length++;
                    MapManager.map[position.x, position.y].type = "Wall";
                }
                else {
                    MapManager.map[position.x, position.y].type = "Floor";
                }
            }
        }

        room.width = roomWidth;
        room.height = roomHeight;
        room.type = "Room";
    }

    void DrawMap(bool isASCII) {
        if (isASCII) {
            Text screen = GameObject.Find("ASCIITest").GetComponent<Text>();

            string asciiMap = "";

            for (int y = (mapHeight - 1); y >= 0; y--) {
                for (int x = 0; x < mapWidth; x++) {
                    if (MapManager.map[x,y] != null) {
                        switch (MapManager.map[x, y].type) {
                            case "Wall":
                                asciiMap += "#";
                                break;
                            case "Floor":
                                asciiMap += ".";
                                break;
                        }
                    } else {
                        asciiMap += " ";
                    }

                    if (x == (mapWidth - 1)) {
                        asciiMap += "\n";
                    }
                }
            }

            screen.text = asciiMap;
        }
    }
}

GameManager.cs:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class GameManager : MonoBehaviour
{
    DungeonGenerator dungeonGenerator;
    
    void Start() {
        dungeonGenerator = GetComponent<DungeonGenerator>();

        dungeonGenerator.InitializeDungeon();
        dungeonGenerator.GenerateDungeon();
    }
}

All Articles