كيفية إنشاء لعبة إذا لم تكن فنانًا أبدًا


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

ولا ...

مقدمة صغيرة


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

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

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

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

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

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

"آه !!! الرعب! كابوس! كيف يمكنك قضاء بعض الوقت على مثل هذا الهراء! أخرج من هنا ، سأذهب لقراءة شيء أكثر إثارة للاهتمام! "

لماذا هذا؟ يعني ، إعادة اختراع العجلة؟ لماذا لا تستخدم محرك اللعبة الجاهزة؟ الجواب بسيط: لا نعرف شيئًا عنه ، لكننا نريد اللعبة الآن. تخيل عقلية المبرمج العادي: "أريد أن أجعل لعبة! سيكون هناك لحم وانفجارات وضخ ، ويمكنك سرقة كوروفان ، والمؤامرة تفجير ، وهذا لم يحدث في أي مكان آخر! سأبدأ الكتابة الآن! .. وعلى ماذا؟ دعونا نرى ما هو شائع معنا الآن ... نعم ، X ، Y و Z. دعونا نأخذ Z ، والآن يكتب الجميع على ذلك ... ". ويبدأ في دراسة المحرك. وهو يرمي الفكرة ، لأنه لا يوجد بالفعل ما يكفي من الوقت لذلك. زعنفة. أو ، حسناً ، هذا لا يستسلم ، ولكن بدون تعلم المحرك حقًا ، يتم استخدامه للعبة. حسنًا ، إذا كان لديه ضمير حتى لا يُظهر لأحد "حرفته" الأولى. عادة لا (انتقل إلى أي متجر تطبيقات ، انظر لنفسك) - حسناً ، حسناً ، أريد الأرباح ، ولا قوة تحمل. بمجرد إنشاء الألعاب كان الكثير من المبدعين المتحمسين. للأسف ، هذه المرة مرت بشكل لا رجعة فيه - الآن الشيء الرئيسي في اللعبة ليس الروح ، ولكن نموذج الأعمال (على الأقل هناك العديد من المحادثات حول هذا الموضوع). هدفنا بسيط: سنجعل الألعاب مع الروح. لذلك ، نستخلص من الأداة (أي شخص سيفعل) ونركز على المهمة.

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

هناك خيارات:

1. ارسم كل شيء بنفسك في محرر رسومي بسيط

لقطات لعبة "Kill Him All" ، 2003

2. ارسم كل شيء بنفسك في ناقل

لقطات من لعبة "Raven" ، 2001


لقطات من لعبة "جحيم" ، 2002

3. اسأل أخًا لا يعرف أيضًا كيف يرسم (لكن هل هو أفضل قليلاً)

لقطات من لعبة "سخيف" ، 2004

4. قم بتنزيل بعض البرامج للنمذجة ثلاثية الأبعاد واسحب الأصول من هناك

لقطات من لعبة "Fucking 2. Demo" ، 2006

5. في اليأس ، تمزيق الشعر على الرأس


لقطات من لعبة "سخيف" ، 2004

6. ارسم كل شيء بنفسك في pseudographics (ASCII)

لقطات من لعبة "Fifa" ، 2000


لقطات من لعبة "Sumo" ، 1998

دعونا نتحدث عن هذا الأخير (جزئياً لأنه لا يبدو محبطاً مثل بقية الناس). يعتقد الكثير من اللاعبين عديمي الخبرة أن الألعاب التي لا تحتوي على رسومات حديثة رائعة لا يمكنها كسب قلوب اللاعبين - حتى اسم اللعبة لا يحولهم إلى ألعاب. يعترض مطورو هذه الروائع مثل ADOM و NetHack و Dwarf Fortress على مثل هذه الحجج. المظهر ليس دائمًا عاملاً حاسماً ، فإن استخدام ASCII يعطي بعض المزايا المهمة:

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

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

الخطوة الأولى فكرة


كيف؟ لا يزال لديك أي فكرة؟

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

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

لكننا سنذهب بهذه الطريقة البسيطة - لنفترض أن لدينا بالفعل فكرة ، ولم نفكر فيها لفترة طويلة. كأول مشروع عظمي لدينا ، سنقوم باستنساخ لعبة جيدة من Obsidian - Pathfinder Adventures .

"ماذا بحق الجحيم هذا! أي الجداول؟ "

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

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

لأولئك الذين ليسوا على دراية بالقواعد ، مقدمة موجزة:
تعد Pathfinder Adventures إصدارًا رقميًا من لعبة بطاقة اللوحة التي تم إنشاؤها على أساس لعبة لعب الأدوار على متنها (أو بالأحرى نظام لعب الأدوار بالكامل) Pathfinder. يختار اللاعبون (من 1 إلى 6) شخصية لأنفسهم ، ويذهبون معه ، في مغامرة ، مقسمة إلى عدد من السيناريوهات. كل حرف لديه تحت تصرفه بطاقات من أنواع مختلفة (مثل: الأسلحة ، والدروع ، والتعاويذ ، والحلفاء ، والعناصر ، وما إلى ذلك) ، بمساعدة في كل سيناريو ، يجب عليه العثور على Scoundrel ومعاقبته بوحشية - بطاقة خاصة ذات خصائص خاصة.

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

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

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

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

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

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

هل سيكون متعة اللعب؟ ليس لدي أي فكرة ، ولا أحد يستطيع أن يفهم هذا حتى يصبح النموذج الأولي للعمل جاهزًا. لكننا لا نتمتع باللعبة ، ولكن التنمية ، أليس كذلك؟ لذلك ، لا ينبغي أن يكون هناك شك في النجاح.

الخطوة الثانية تصميم


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

في البداية ، سيبدو مستند التصميم الخاص بك مثل هذا




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

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

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

"المؤلف ، تقتل نفسك ضد الجدار. عدد كبير جدًا من الحروف. "

الخطوة الثالثة تصميم


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

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

لنبدأ برسم حالة الاستخدام. سنقوم بتصوير الطرق التي يتفاعل بها المستخدم (اللاعب) مع النظام المستقبلي:

استخدام الحالات


"اه ... ما هذا كله؟"

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

يمكن تفصيل هذه النقطة في مخطط المكونات التالية:

مكونات النظام


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

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

إذا كنت تقف ، اجلس


وأخيرًا ، سيكون من الجيد أن نقدم بشكل عام تسلسل تفاعل المستخدم النهائي مع محرك اللعبة من خلال نظام المدخلات والمخرجات.

النقانق


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

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

الخطوة الرابعة اختيار الأداة


وفقًا لما تم الاتفاق عليه بالفعل ، سنقوم بتطوير تطبيق عبر الأنظمة الأساسية يعمل على كل من أجهزة سطح المكتب التي تعمل على أنظمة تشغيل مختلفة وعلى الأجهزة المحمولة. سنختار Java كلغة برمجة ، وكوتلين أفضل ، لأن الأخير أحدث وأعذب ، ولم يتح له الوقت بعد للسباحة في موجات السخط التي طغت على سابقتها (في نفس الوقت سأتعلم ما إذا كان شخص آخر لا يمتلكها). JVM ، كما تعلمون ، متاح في كل مكان (على ثلاثة مليارات جهاز ، hehe) ، سندعم كلاً من Windows و UNIX ، وحتى على خادم بعيد يمكننا اللعب من خلال اتصال SSH (غير معروف لأي شخص يحتاج إليه ، ولكن سوف نقدم هذه الفرصة). سننقله أيضًا إلى Android عندما نصبح أغنياء ونوظف فنانًا ، لكننا سنزيد ذلك في وقت لاحق.

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

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

الخطوة الخامسة إنشاء وإعداد مشروع


إذا كنت تستخدم IDE ، فإن إنشاء مشروع أمر تافه. تحتاج فقط إلى اختيار اسم رنان (على سبيل المثال ، Dice ) لتحفة المستقبل الخاصة بنا ، ولا تنسَ تمكين دعم Maven في الإعدادات ، pom.xmlوكتابة المعرفات اللازمة في الملف :

 <modelVersion>4.0.0</modelVersion> <groupId>my.company</groupId> <artifactId>dice</artifactId> <version>1.0</version> <packaging>jar</packaging> 

أضف أيضًا دعم Kotlin ، والذي يفتقد افتراضيًا:

 <dependency> <groupId>org.jetbrains.kotlin</groupId> <artifactId>kotlin-stdlib</artifactId> <version>${kotlin.version}</version> </dependency> 

وبعض الإعدادات التي لن نتناولها بالتفصيل:

 <properties> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <maven.compiler.source>1.8</maven.compiler.source> <maven.compiler.target>1.8</maven.compiler.target> <kotlin.version>1.3.20</kotlin.version> <kotlin.compiler.incremental>true</kotlin.compiler.incremental> </properties> 

قليلا من المعلومات بشأن المشاريع المختلطة
Java, Kotlin src/main/kotlin src/main/java . Kotlin , ( *.kt ) , ( *.java ) Maven:

 <build> <plugins> <plugin> <groupId>org.jetbrains.kotlin</groupId> <artifactId>kotlin-maven-plugin</artifactId> <version>${kotlin.version}</version> <executions> <execution> <id>compile</id> <phase>process-sources</phase> <goals> <goal>compile</goal> </goals> <configuration> <sourceDirs> <sourceDir>${project.basedir}/src/main/kotlin</sourceDir> <sourceDir>${project.basedir}/src/main/java</sourceDir> </sourceDirs> </configuration> </execution> <execution> <id>test-compile</id> <goals> <goal>test-compile</goal> </goals> <configuration> <sourceDirs> <sourceDir>${project.basedir}/src/test/kotlin</sourceDir> <sourceDir>${project.basedir}/src/test/java</sourceDir> </sourceDirs> </configuration> </execution> </executions> </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.5.1</version> <executions> <!-- Replacing default-compile --> <execution> <id>default-compile</id> <phase>none</phase> </execution> <!-- Replacing default-testCompile --> <execution> <id>default-testCompile</id> <phase>none</phase> </execution> <execution> <id>java-compile</id> <phase>compile</phase> <goals> <goal>compile</goal> </goals> </execution> <execution> <id>java-test-compile</id> <phase>test-compile</phase> <goals> <goal>testCompile</goal> </goals> </execution> </executions> </plugin> </plugins> </build> 

, — . .

دعنا ننشئ ثلاث حزم في وقت واحد (لماذا تافه شيء؟):

  • model - بالنسبة للفصول التي تصف أشياء من عالم اللعبة ؛
  • game - للفصول التي تنفذ طريقة اللعب ؛
  • ui - للفصول المسؤولة عن تفاعل المستخدم.

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

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

نحن أيضا إنشاء فئة مع وظيفةmainونحن على استعداد لإنجازات كبيرة. يمكنك استخدام IDE نفسه لبدء تشغيله ، ولكن كما سترى لاحقًا ، هذه الطريقة غير مناسبة لأغراضنا (وحدة التحكم IDE القياسية غير قادرة على عرض النتائج الرسومية كما ينبغي) ، لذلك سنقوم بتهيئة الإطلاق من الخارج باستخدام الدُفعات (أو shell على أنظمة UNIX) الملف. ولكن قبل ذلك ، سنقوم بعمل بعض الإعدادات الإضافية.

بعد اكتمال العملية ، mvn packageنحصل على إخراج أرشيف JAR مع جميع الفئات المترجمة. أولاً ، بشكل افتراضي ، لا يشتمل هذا الأرشيف على التبعيات الضرورية للمشروع لكي يعمل (حتى الآن ليس لديناها ، لكنها ستظهر بالتأكيد في المستقبل). ثانياً ، لم يتم تحديد المسار إلى الفئة الرئيسية التي تحتوي على الطريقة في ملف بيان الأرشيف main، لذا ابدأ المشروع بالأمرjava -jar dice-1.0.jarلن تنجح معنا. إصلاح هذا عن طريق إضافة إعدادات إضافية إلى pom.xml:

 <build> <plugins> <plugin> <artifactId>maven-assembly-plugin</artifactId> <version>2.6</version> <executions> <execution> <phase>package</phase> <goals> <goal>single</goal> </goals> </execution> </executions> <configuration> <descriptorRefs> <descriptorRef>jar-with-dependencies</descriptorRef> </descriptorRefs> <archive> <manifest> <mainClass>my.company.dice.MainKt</mainClass> </manifest> </archive> </configuration> </plugin> </plugins> </build> 

انتبه إلى اسم الفئة الرئيسية. بالنسبة إلى وظائف Kotlin الموجودة خارج الفئات (مثل الوظائف ، على سبيل المثال main) ، لا تزال يتم إنشاء الفئات أثناء الترجمة (لأن JVM لا تعرف شيئًا ولا تريد أن تعرف). اسم هذه الفئة هو اسم الملف مع الإضافة Kt. وهذا هو ، إذا قمت بتسمية الفئة الرئيسية Main، فسيتم تجميعها في ملف MainKt.class. هذا هو الأخير الذي يجب أن نشير في بيان ملف الجرة.

الآن ، عند بناء المشروع ، سنحصل على ملفي جرة: dice-1.0.jarو dice-1.0-jar-with-dependencies.jar. نحن مهتمون بالثاني. سنكتب النصي إطلاق لذلك.

dice.bat (لنظام التشغيل Windows)

 @ECHO OFF rem Compiling call "path_to_maven\mvn.bat" -f "path_to_project\Dice\pom.xml" package if errorlevel 1 echo Project compilation failed! & pause & goto :EOF rem Running java -jar path_to_project\Dice\target\dice-1.0-jar-with-dependencies.jar pause 

dice.sh (لـ UNIX)

 #!/bin/sh # Compiling mvn -f "path_to_project/Dice/pom.xml" package if [[ "$?" -ne 0 ]] ; then echo 'Project compilation failed!'; exit $rc fi # Running java -jar path_to_project/Dice/target/dice-1.0-jar-with-dependencies.jar 

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

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

الخطوة السادسة الأشياء الرئيسية


تدريجيا ، سنبدأ في ملء الحزمة مع modelالفئات اللازمة للعب.

مخطط الطبقة


مكعبات هي كل شيء لدينا ، إضافتها أولا. Dieيتميز كل مكعب (مثيل فئة ) بنوع (اللون) والحجم. بالنسبة لأنواع المكعب Die.Type، سنقوم بإعداد تعداد منفصل ( ) ، ووضع علامة على الحجم باستخدام عدد صحيح من 4 إلى 12. نطبق أيضًا طريقة roll()تنتج رقمًا تعسفيًا وموزعًا بشكل منتظم من النطاق المتاح للمكعب (من 1 إلى قيمة الحجم شاملة).

يطبق الفصل الواجهة Comparableبحيث يمكن مقارنة المكعبات مع بعضها البعض (مفيد لاحقًا عندما نعرض عدة مكعبات في صف مرتب). سيتم وضع مكعبات أكبر في وقت سابق.

 class Die(val type: Type, val size: Int) : Comparable<Die> { enum class Type { PHYSICAL, //Blue SOMATIC, //Green MENTAL, //Purple VERBAL, //Yellow DIVINE, //Cyan WOUND, //Gray ENEMY, //Red VILLAIN, //Orange OBSTACLE, //Brown ALLY //White } fun roll() = (1.. size).random() override fun toString() = "d$size" override fun compareTo(other: Die): Int { return compareValuesBy(this, other, Die::type, { -it.size }) } } 

, ( Bag ). , , , . . (sets) , . -, equals() hashCode() , , — . -, , - , , . () ( put()) أو مباشرة قبل الإصدار (في الطريقة draw()).

هذه الطريقة examine()مناسبة للحالات التي يتخلص فيها اللاعب الذي سئم من حالة عدم اليقين من محتويات الكيس في القلوب على الطاولة (انتبه إلى الفرز) ، والطريقة clear()- إذا لم تعد المكعبات المهتزة إلى الكيس.

 open class Bag { protected val dice = LinkedList<Die>() val size get() = dice.size fun put(vararg dice: Die) { dice.forEach(this.dice::addLast) this.dice.shuffle() } fun draw(): Die = dice.pollFirst() fun clear() = dice.clear() fun examine() = dice.sorted().toList() } 

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

 class Pile : Bag() { fun removeDie(die: Die) = dice.remove(die) } 

ننتقل الآن إلى شخصياتنا الرئيسية - الأبطال. وهذا هو ، الشخصيات التي سوف نسميها الآن الأبطال (هناك سبب وجيه لعدم تسمية فصلك باسم Characterفي Java). هناك أنواع مختلفة من الشخصيات (لوضعها في فصول ، على الرغم من أنه من classالأفضل عدم استخدام الكلمة ) ، لكن بالنسبة لنموذج العمل الأولي الخاص بنا ، سوف نأخذ حالتين فقط: Brawler (أي ، Fighter مع التركيز على القوة والقوة) و Hunter (الملقب Ranger / Thief ، مع التركيز على البراعة والشبح). تحدد فئة البطل خصائصه ومهاراته ومجموعة المكعبات الأولية ، ولكن كما سيظهر لاحقًا ، لن يتم ربط الأبطال بشكل صارم بالصفوف ، وبالتالي يمكن تغيير إعداداتهم الشخصية بسهولة في مكان واحد.

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

 data class Hero(val type: Type) { enum class Type { BRAWLER HUNTER } var name = "" var isAlive = true var favoredDieType: Die.Type = Die.Type.ALLY val hand = Hand(0) val bag: Bag = Bag() val discardPile: Pile = Pile() private val diceLimits = mutableListOf<DiceLimit>() private val skills = mutableListOf<Skill>() private val dormantSkills = mutableListOf<Skill>() fun addDiceLimit(limit: DiceLimit) = diceLimits.add(limit) fun getDiceLimits(): List<DiceLimit> = Collections.unmodifiableList(diceLimits) fun addSkill(skill: Skill) = skills.add(skill) fun getSkills(): List<Skill> = Collections.unmodifiableList(skills) fun addDormantSkill(skill: Skill) = dormantSkills.add(skill) fun getDormantSkills(): List<Skill> = Collections.unmodifiableList(dormantSkills) fun increaseDiceLimit(type: Die.Type) { diceLimits.find { it.type == type }?.let { when { it.current < it.maximal -> it.current++ else -> throw IllegalArgumentException("Already at maximum") } } ?: throw IllegalArgumentException("Incorrect type specified") } fun hideDieFromHand(die: Die) { bag.put(die) hand.removeDie(die) } fun discardDieFromHand(die: Die) { discardPile.put(die) hand.removeDie(die) } fun hasSkill(type: Skill.Type) = skills.any { it.type == type } fun improveSkill(type: Skill.Type) { dormantSkills .find { it.type == type } ?.let { skills.add(it) dormantSkills.remove(it) } skills .find { it.type == type } ?.let { when { it.level < it.maxLevel -> it.level += 1 else -> throw IllegalStateException("Skill already maxed out") } } ?: throw IllegalArgumentException("Skill not found") } } 

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

 class Hand(var capacity: Int) { private val dice = LinkedList<Die>() private val allies = LinkedList<Die>() val dieCount get() = dice.size val allyDieCount get() = allies.size fun dieAt(index: Int) = when { (index in 0 until dieCount) -> dice[index] else -> null } fun allyDieAt(index: Int) = when { (index in 0 until allyDieCount) -> allies[index] else -> null } fun addDie(die: Die) = when { die.type == Die.Type.ALLY -> allies.addLast(die) else -> dice.addLast(die) } fun removeDie(die: Die) = when { die.type == Die.Type.ALLY -> allies.remove(die) else -> dice.remove(die) } fun findDieOfType(type: Die.Type): Die? = when (type) { Die.Type.ALLY -> if (allies.isNotEmpty()) allies.first else null else -> dice.firstOrNull { it.type == type } } fun examine(): List<Die> = (dice + allies).sorted() } 

DiceLimitتحدد مجموعة كائنات الفصل حدود عدد مكعبات كل نوع يمكن أن يكون للبطل في بداية البرنامج النصي. لا يوجد شيء خاص للقول ، نحن نحدد في البداية ، الحد الأقصى والقيم الحالية لكل نوع.

 class DiceLimit(val type: Die.Type, val initial: Int, val maximal: Int, var current: Int) 

لكن بالمهارات يكون الأمر أكثر إثارة للاهتمام. سيتعين تنفيذ كل منها على حدة (حول ذلك لاحقًا) ، لكننا سننظر في اثنين فقط: Hit و Shoot (واحد لكل فئة ، على التوالي). يمكن تطوير المهارات ("ضخ") من المستوى الأولي إلى المستوى الأقصى ، مما يؤثر غالبًا على المعدلات التي تضاف إلى لفائف النرد. يعكس هذا في الخصائص level، maxLevel، modifier1و modifier2.

 class Skill(val type: Type) { enum class Type { //Brawler HIT, //Hunter SHOOT, } var level = 1 var maxLevel = 3 var isActive = true var modifier1 = 0 var modifier2 = 0 } 

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

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

"لقد مرضني شيء ما. سأذهب لتدخين أو شيء ... "

وسوف نستمر.
, — . , .



: ( Villain ), ( Enemy ) ( Obstacle ), «» ( Threat — «» , ). ( Trait ), .

 sealed class Threat { var name: String = "" var description: String = "" private val traits = mutableListOf<Trait>() fun addTrait(trait: Trait) = traits.add(trait) fun getTraits(): List<Trait> = traits } class Obstacle(val tier: Int, vararg val dieTypes: Die.Type) : Threat() class Villain : Threat() class Enemy : Threat() enum class Trait { MODIFIER_PLUS_ONE, //Add +1 modifier MODIFIER_PLUS_TWO, //Add +2 modifier } 

لاحظ أنه يتم Traitتعريف قائمة كائنات الفئة على أنها قابلة للتغيير ( MutableList) ، ولكن يتم تقديمها للخارج كواجهة ثابتة List. على الرغم من أن هذا سيعمل في Kotlin ، إلا أن هذا الأسلوب غير آمن ، حيث لا يوجد شيء يمنع تحويل القائمة الناتجة إلى واجهة قابلة للتغيير وإجراء تعديلات متنوعة - من السهل القيام بذلك بشكل خاص إذا قمت بالوصول إلى الفئة من كود Java (حيث تكون الواجهة Listقابلة للتغيير). الطريقة الأكثر حماقة لحماية مجموعتك هي القيام بشيء من هذا القبيل:

 fun getTraits(): List<Trait> = Collections.unmodifiableList(traits) 

لكننا لن نكون دقيقين للغاية في تناول القضية (ومع ذلك ، يتم تحذيرك).

نظرًا لخصائص ميكانيكا الألعاب ، Obstacleيختلف الفصل الدراسي عن نظرائه بسبب وجود حقول إضافية ، لكننا لن نركز عليها.

يتم دمج بطاقات التهديد (وإذا كنت تقرأ بعناية وثيقة التصميم ، ثم تذكر أن هذه بطاقات) في الطوابق التي يمثلها الفصل Deck:

 class Deck<E: Threat> { private val cards = LinkedList<E>() val size get() = cards.size fun addToTop(card: E) = cards.addFirst(card) fun addToBottom(card: E) = cards.addLast(card) fun revealTop(): E = cards.first fun drawFromTop(): E = cards.removeFirst() fun shuffle() = cards.shuffle() fun clear() = cards.clear() fun examine() = cards.toList() } 

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

... فئة Location، كل حالة منها تصف مجالًا فريدًا يتعين على أبطالنا زيارته في السيناريو.

 class Location { var name: String = "" var description: String = "" var isOpen = true var closingDifficulty = 0 lateinit var bag: Bag var villain: Villain? = null lateinit var enemies: Deck<Enemy> lateinit var obstacles: Deck<Obstacle> private val specialRules = mutableListOf<SpecialRule>() fun addSpecialRule(rule: SpecialRule) = specialRules.add(rule) fun getSpecialRules() = specialRules } 

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

أخيرًا ، يبقى تنفيذ البرامج النصية (الفئة Scenario):

 class Scenario { var name = "" var description = "" var level = 0 var initialTimer = 0 private val allySkills = mutableListOf<AllySkill>() private val specialRules = mutableListOf<SpecialRule>() fun addAllySkill(skill: AllySkill) = allySkills.add(skill) fun getAllySkills(): List<AllySkill> = Collections.unmodifiableList(allySkills) fun addSpecialRule(rule: SpecialRule) = specialRules.add(rule) fun getSpecialRules(): List<SpecialRule> = Collections.unmodifiableList(specialRules) } 

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

أذكركم بأن جميع الفئات التي تم استعراضها مسبقًا موجودة في الحزمةmodel- ونحن كطفل ، تحسباً لمعركة لعبة ملحمية ، وضعنا الجنود على سطح الطاولة. والآن ، بعد بضع لحظات مؤلمة ، في إشارة من القائد العام ، سوف نندفع إلى المعركة ، ونجمع ألعابنا معًا ونستمتع بعواقب اللعب. ولكن قبل ذلك ، قليلا عن الترتيب نفسه.

"حسنا سوو ..."

الخطوة السابعة. الأنماط والمولدات


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

 val location = Location().apply { name = "Some location" description = "Some description" isOpen = true closingDifficulty = 4 bag = Bag().apply { put(Die(Die.Type.PHYSICAL, 4)) put(Die(Die.Type.SOMATIC, 4)) put(Die(Die.Type.MENTAL, 4)) put(Die(Die.Type.ENEMY, 6)) put(Die(Die.Type.OBSTACLE, 6)) put(Die(Die.Type.VILLAIN, 6)) } villain = Villain().apply { name = "Some villain" description = "Some description" addTrait(Trait.MODIFIER_PLUS_ONE) } enemies = Deck<Enemy>().apply { addToTop(Enemy().apply { name = "Some enemy" description = "Some description" }) addToTop(Enemy().apply { name = "Other enemy" description = "Some description" }) shuffle() } obstacles = Deck<Obstacle>().apply { addToTop(Obstacle(1, Die.Type.PHYSICAL, Die.Type.VERBAL).apply { name = "Some obstacle" description = "Some Description" }) } } 

هذا أيضًا بفضل لغة Kotlin وتصميمها apply{}- في Java ، سيكون الكود كبيرًا جدًا. علاوة على ذلك ، سيكون هناك العديد من الأماكن ، كما قلنا ، وإلى جانبها ، هناك أيضًا سيناريوهات ومغامرات وأبطال بمهاراتهم وخصائصهم - بشكل عام ، هناك شيء يفعله مصمم اللعبة.

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

عملية إنشاء كائن من قالب


وبالتالي ، يجب تعريف كيانين جديدين لكل فئة من الكائنات: واجهة القالب وفئة المولد. ونظرًا لتراكم كمية مناسبة من الكائنات ، فسيكون هناك أيضًا عدد من الكيانات ... غير لائق:

مخطط الطبقة


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

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

 interface DieTypeFilter { fun test(type: Die.Type): Boolean } 

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

 class SingleDieTypeFilter(val type: Die.Type): DieTypeFilter { override fun test(type: Die.Type) = (this.type == type) } class InvertedSingleDieTypeFilter(val type: Die.Type): DieTypeFilter { override fun test(type: Die.Type) = (this.type != type) } class MultipleDieTypeFilter(vararg val types: Die.Type): DieTypeFilter { override fun test(type: Die.Type) = (type in types) } class InvertedMultipleDieTypeFilter(vararg val types: Die.Type): DieTypeFilter { override fun test(type: Die.Type) = (type !in types) } 

سيتم أيضًا تعيين حجم المكعب بشكل تعسفي ، ولكن سيتم تحديد حجمه لاحقًا. في غضون ذلك ، سنقوم بكتابة مولد مكعبات ( DieGenerator) ، على عكس مُنشئ الفصل Die، لن يقبل نوع وحجم المكعب الصريح ، ولكن عامل التصفية ومستوى التعقيد.

 private val DISTRIBUTION_LEVEL1 = intArrayOf(4, 4, 4, 4, 6, 6, 6, 6, 8) private val DISTRIBUTION_LEVEL2 = intArrayOf(4, 6, 6, 6, 6, 8, 8, 8, 8, 10) private val DISTRIBUTION_LEVEL3 = intArrayOf(6, 8, 8, 8, 10, 10, 10, 10, 12, 12, 12) private val DISTRIBUTIONS = arrayOf( intArrayOf(4), DISTRIBUTION_LEVEL1, DISTRIBUTION_LEVEL2, DISTRIBUTION_LEVEL3 ) fun getMaxLevel() = DISTRIBUTIONS.size - 1 fun generateDie(filter: DieTypeFilter, level: Int) = Die(generateDieType(filter), generateDieSize(level)) private fun generateDieType(filter: DieTypeFilter): Die.Type { var type: Die.Type do { type = Die.Type.values().random() } while (!filter.test(type)) return type } private fun generateDieSize(level: Int) = DISTRIBUTIONS[if (level < 1 || level > getMaxLevel()) 0 else level].random() 

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

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

 override fun test(filter: DieTypeFilter) = false 

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

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

 class BagTemplate { class Plan(val minQuantity: Int, val maxQuantity: Int, val filter: DieTypeFilter) val plans = mutableListOf<Plan>() fun addPlan(minQuantity: Int, maxQuantity: Int, filter: DieTypeFilter) { plans.add(Plan(minQuantity, maxQuantity, filter)) } } 

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

 private fun realizePlan(plan: BagTemplate.Plan, level: Int): Array<Die> { val count = (plan.minQuantity..plan.maxQuantity).shuffled().last() return (1..count).map { generateDie(plan.filter, level) }.toTypedArray() } fun generateBag(template: BagTemplate, level: Int): Bag { return template.plans.asSequence() .map { realizePlan(it, level) } .fold(Bag()) { b, d -> b.put(*d); b } } } 

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

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

 interface LocationTemplate { val name: String val description: String val bagTemplate: BagTemplate val basicClosingDifficulty: Int val enemyCardsCount: Int val obstacleCardsCount: Int val enemyCardPool: Collection<EnemyTemplate> val obstacleCardPool: Collection<ObstacleTemplate> val specialRules: List<SpecialRule> } 

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

 interface EnemyTemplate { val name: String val description: String val traits: List<Trait> } interface ObstacleTemplate { val name: String val description: String val tier: Int val dieTypes: Array<Die.Type> val traits: List<Trait> } interface VillainTemplate { val name: String val description: String val traits: List<Trait> } 

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

 fun generateVillain(template: VillainTemplate) = Villain().apply { name = template.name description = template.description template.traits.forEach { addTrait(it) } } fun generateEnemy(template: EnemyTemplate) = Enemy().apply { name = template.name description = template.description template.traits.forEach { addTrait(it) } } fun generateObstacle(template: ObstacleTemplate) = Obstacle(template.tier, *template.dieTypes).apply { name = template.name description = template.description template.traits.forEach { addTrait(it) } } fun generateEnemyDeck(types: Collection<EnemyTemplate>, limit: Int?): Deck<Enemy> { val deck = types .map { generateEnemy(it) } .shuffled() .fold(Deck<Enemy>()) { d, c -> d.addToTop(c); d } limit?.let { while (deck.size > it) deck.drawFromTop() } return deck } fun generateObstacleDeck(templates: Collection<ObstacleTemplate>, limit: Int?): Deck<Obstacle> { val deck = templates .map { generateObstacle(it) } .shuffled() .fold(Deck<Obstacle>()) { d, c -> d.addToTop(c); d } limit?.let { while (deck.size > it) deck.drawFromTop() } return deck } 

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

 fun generateLocation(template: LocationTemplate, level: Int) = Location().apply { name = template.name description = template.description bag = generateBag(template.bagTemplate, level) closingDifficulty = template.basicClosingDifficulty + level * 2 enemies = generateEnemyDeck(template.enemyCardPool, template.enemyCardsCount) obstacles = generateObstacleDeck(template.obstacleCardPool, template.obstacleCardsCount) template.specialRules.forEach { addSpecialRule(it) } } 

الآن ، ستظهر التضاريس التي قمنا بتعيينها بشكل صريح في الكود في بداية الفصل نظرة مختلفة تمامًا:

 class SomeLocationTemplate: LocationTemplate { override val name = "Some location" override val description = "Some description" override val bagTemplate = BagTemplate().apply { addPlan(1, 1, SingleDieTypeFilter(Die.Type.PHYSICAL)) addPlan(1, 1, SingleDieTypeFilter(Die.Type.SOMATIC)) addPlan(1, 2, SingleDieTypeFilter(Die.Type.MENTAL)) addPlan(2, 2, MultipleDieTypeFilter(Die.Type.ENEMY, Die.Type.OBSTACLE)) } override val basicClosingDifficulty = 2 override val enemyCardsCount = 2 override val obstacleCardsCount = 1 override val enemyCardPool = listOf( SomeEnemyTemplate(), OtherEnemyTemplate() ) override val obstacleCardPool = listOf( SomeObstacleTemplate() ) override val specialRules = emptyList<SpecialRule>() } class SomeEnemyTemplate: EnemyTemplate { override val name = "Some enemy" override val description = "Some description" override val traits = emptyList<Trait>() } class OtherEnemyTemplate: EnemyTemplate { override val name = "Other enemy" override val description = "Some description" override val traits = emptyList<Trait>() } class SomeObstacleTemplate: ObstacleTemplate { override val name = "Some obstacle" override val description = "Some description" override val traits = emptyList<Trait>() override val tier = 1 override val dieTypes = arrayOf( Die.Type.PHYSICAL, Die.Type.VERBAL ) } val location = generateLocation(SomeLocationTemplate(), 1) 

سيحدث توليد السيناريو بطريقة مماثلة.

 interface ScenarioTemplate { val name: String val description: String val initialTimer: Int val staticLocations: List<LocationTemplate> val dynamicLocationsPool: List<LocationTemplate> val villains: List<VillainTemplate> val specialRules: List<SpecialRule> fun calculateDynamicLocationsCount(numberOfHeroes: Int) = numberOfHeroes + 2 } 

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

 fun generateScenario(template: ScenarioTemplate, level: Int) = Scenario().apply { name =template.name description = template.description this.level = level initialTimer = template.initialTimer template.specialRules.forEach { addSpecialRule(it) } } fun generateLocations(template: ScenarioTemplate, level: Int, numberOfHeroes: Int): List<Location> { val locations = template.staticLocations.map { generateLocation(it, level) } + template.dynamicLocationsPool .map { generateLocation(it, level) } .shuffled() .take(template.calculateDynamicLocationsCount(numberOfHeroes)) val villains = template.villains .map(::generateVillain) .shuffled() locations.forEachIndexed { index, location -> if (index < villains.size) { location.villain = villains[index] location.bag.put(generateDie(SingleDieTypeFilter(Die.Type.VILLAIN), level)) } } return locations } 

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

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

 interface HeroTemplate { val type: Hero.Type val initialHandCapacity: Int val favoredDieType: Die.Type val initialDice: Collection<Die> val initialSkills: List<SkillTemplate> val dormantSkills: List<SkillTemplate> fun getDiceCount(type: Die.Type): Pair<Int, Int>? } 

وعلى الفور نلاحظ اثنين من الشذوذ. أولاً ، لا نستخدم القوالب لإنشاء أكياس ومكعبات فيها. لماذا؟ () — . -, getDiceCount() — ??? , DiceLimit , . , . :

 class BrawlerHeroTemplate : HeroTemplate { override val type = Hero.Type.BRAWLER override val favoredDieType = PHYSICAL override val initialHandCapacity = 4 override val initialDice = listOf( Die(PHYSICAL, 6), Die(PHYSICAL, 6), Die(PHYSICAL, 4), Die(PHYSICAL, 4), Die(PHYSICAL, 4), Die(PHYSICAL, 4), Die(PHYSICAL, 4), Die(PHYSICAL, 4), Die(SOMATIC, 6), Die(SOMATIC, 4), Die(SOMATIC, 4), Die(SOMATIC, 4), Die(MENTAL, 4), Die(VERBAL, 4), Die(VERBAL, 4) ) override fun getDiceCount(type: Die.Type) = when (type) { PHYSICAL -> 8 to 12 SOMATIC -> 4 to 7 MENTAL -> 1 to 2 VERBAL -> 2 to 4 else -> null } override val initialSkills = listOf( HitSkillTemplate() ) override val dormantSkills = listOf<SkillTemplate>() } class HunterHeroTemplate : HeroTemplate { override val type = Hero.Type.HUNTER override val favoredDieType = SOMATIC override val initialHandCapacity = 5 override val initialDice = listOf( Die(PHYSICAL, 4), Die(PHYSICAL, 4), Die(PHYSICAL, 4), Die(SOMATIC, 6), Die(SOMATIC, 6), Die(SOMATIC, 4), Die(SOMATIC, 4), Die(SOMATIC, 4), Die(SOMATIC, 4), Die(SOMATIC, 4), Die(MENTAL, 6), Die(MENTAL, 4), Die(MENTAL, 4), Die(MENTAL, 4), Die(VERBAL, 4) ) override fun getDiceCount(type: Die.Type) = when (type) { PHYSICAL -> 3 to 5 SOMATIC -> 7 to 11 MENTAL -> 4 to 7 VERBAL -> 1 to 2 else -> null } override val initialSkills = listOf( ShootSkillTemplate() ) override val dormantSkills = listOf<SkillTemplate>() } 

, .

 interface SkillTemplate { val type: Skill.Type val maxLevel: Int val modifier1: Int val modifier2: Int val isActive get() = true } class HitSkillTemplate : SkillTemplate { override val type = Skill.Type.HIT override val maxLevel = 3 override val modifier1 = +1 override val modifier2 = +3 } class ShootSkillTemplate : SkillTemplate { override val type = Skill.Type.SHOOT override val maxLevel = 3 override val modifier1 = +0 override val modifier2 = +2 } 

, , , . , — . , , . , .

 fun generateSkill(template: SkillTemplate, initialLevel: Int = 1): Skill { val skill = Skill(template.type) skill.isActive = template.isActive skill.level = initialLevel skill.maxLevel = template.maxLevel skill.modifier1 = template.modifier1 skill.modifier2 = template.modifier2 return skill } fun generateHero(type: Hero.Type, name: String = ""): Hero { val template = when (type) { BRAWLER -> BrawlerHeroTemplate() HUNTER -> HunterHeroTemplate() } val hero = Hero(type) hero.name = name hero.isAlive = true hero.favoredDieType = template.favoredDieType hero.hand.capacity = template.initialHandCapacity template.initialDice.forEach { hero.bag.put(it) } for ((t, l) in Die.Type.values().map { it to template.getDiceCount(it) }) { l?.let { hero.addDiceLimit(DiceLimit(t, it.first, it.second, it.first)) } } template.initialSkills .map { generateSkill(it) } .forEach { hero.addSkill(it) } template.dormantSkills .map { generateSkill(it, 0) } .forEach { hero.addDormantSkill(it) } return hero } 

. -, . -, ( ). -, Kotlin , . .

.


- — . -, « ». , . , … ( ), . , .



, , . , ( ) . (, ) — ? , - . , — , . , , . , , . . — , , ( — , — له الأحلام).

لذلك ، فإن أول شيء فعله هو تحديد الأشياء التي نحتاجها.

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

 class TestLocationTemplate : LocationTemplate { override val name = "Test" override val description = "Some Description" override val basicClosingDifficulty = 0 override val enemyCardsCount = 0 override val obstacleCardsCount = 0 override val bagTemplate = BagTemplate().apply { addPlan(2, 2, SingleDieTypeFilter(Die.Type.PHYSICAL)) addPlan(2, 2, SingleDieTypeFilter(Die.Type.SOMATIC)) addPlan(2, 2, SingleDieTypeFilter(Die.Type.MENTAL)) addPlan(2, 2, SingleDieTypeFilter(Die.Type.VERBAL)) addPlan(2, 2, SingleDieTypeFilter(Die.Type.DIVINE)) } override val enemyCardPool = emptyList<EnemyTemplate>() override val obstacleCardPool = emptyList<ObstacleTemplate>() override val specialRules = emptyList<SpecialRule>() } 

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

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

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

 class DiePair(val die: Die, var modifier: Int = 0) 

موقع الشخصيات في المنطقة.
بطريقة جيدة ، يجب تتبع هذه اللحظة باستخدام هيكل خاص. على سبيل المثال ، خرائط النموذج Map<Location, List<Hero>>حيث ستحتوي كل منطقة على قائمة بالأبطال الموجودين فيها حاليًا (بالإضافة إلى طريقة للعكس - تحديد المكان الذي يوجد به بطل معين). إذا قررت السير في هذا الطريق، لا تنس أن تضيف فئة Locationتطبيقات طريقة equals()و hashCode()- نأمل أن لا تحتاج إلى شرح لماذا. لن نضيع الوقت في هذا ، لأن المنطقة واحدة فقط والأبطال لا يتركونها في أي مكان.

التحقق من أيدي البطل.
( ), , ( ), , (, /, ), (, ) . , , , . HandFilter .

 interface HandFilter { fun test(hand: Hand): Boolean } 

تأخذ تطبيقات الواجهة يد البطل (كائن فئة Hand) كمدخل وإرجاع trueإما falseاعتمادًا على نتائج الفحص. بالنسبة لشظتنا من اللعبة ، نحتاج إلى تطبيق واحد: في حالة مواجهة مكعب أزرق أو أخضر أو ​​بنفسجي أو أصفر ، نحتاج إلى تحديد ما إذا كانت يد البطل تحتوي على مكعب من نفس اللون.

 class SingleDieHandFilter(private vararg val types: Die.Type) : HandFilter { override fun test(hand: Hand) = (0 until hand.dieCount).mapNotNull { hand.dieAt(it) }.any { it.type in types } || (Die.Type.ALLY in types && hand.allyDieCount > 0) } 

, .

/ .
, , , , ( ), . -, () ( ). -, - . HandMask , , , ( ) .

 class HandMask { private val positions = mutableSetOf<Int>() private val allyPositions = mutableSetOf<Int>() val positionCount get() = positions.size val allyPositionCount get() = allyPositions.size fun addPosition(position: Int) = positions.add(position) fun removePosition(position: Int) = positions.remove(position) fun addAllyPosition(position: Int) = allyPositions.add(position) fun removeAllyPosition(position: Int) = allyPositions.remove(position) fun checkPosition(position: Int) = position in positions fun checkAllyPosition(position: Int) = position in allyPositions fun switchPosition(position: Int) { if (!removePosition(position)) { addPosition(position) } } fun switchAllyPosition(position: Int) { if (!removeAllyPosition(position)) { addAllyPosition(position) } } fun clear() { positions.clear() allyPositions.clear() } } 

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

بالمناسبة ، يجب تطبيق فئة مماثلة لتحديد مكعبات من heap ( PileMask) ، ولكن هذه الوظيفة خارج نطاق هذا المثال.

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

 abstract class HandMaskRule(val hand: Hand) { abstract fun checkMask(mask: HandMask): Boolean abstract fun isPositionActive(mask: HandMask, position: Int): Boolean abstract fun isAllyPositionActive(mask: HandMask, position: Int): Boolean fun getCheckedDice(mask: HandMask): List<Die> { return ((0 until hand.dieCount).filter(mask::checkPosition).map(hand::dieAt)) .plus((0 until hand.allyDieCount).filter(mask::checkAllyPosition).map(hand::allyDieAt)) .filterNotNull() } } 

, , . . ( Hand ), . ( HandMask ), ( , ). checkMask() , . isPositionActive() , — ( , ). isAllyPositionActive() — (, , ). getCheckedDice() , — , , .

(, !). ( ). , .

 class StatDieAcquireHandMaskRule(hand: Hand, private val requiredType: Die.Type) : HandMaskRule(hand) { /** * Define how many dice of specified type are currently checked */ private fun checkedDieCount(mask: HandMask) = (0 until hand.dieCount) .filter(mask::checkPosition) .mapNotNull(hand::dieAt) .count { it.type === requiredType } override fun checkMask(mask: HandMask) = (mask.allyPositionCount == 0 && checkedDieCount(mask) == 1) override fun isPositionActive(mask: HandMask, position: Int) = with(hand.dieAt(position)) { when { mask.checkPosition(position) -> true this == null -> false this.type === Die.Type.DIVINE -> true this.type === requiredType && checkedDieCount(mask) < 1 -> true else -> false } } override fun isAllyPositionActive(mask: HandMask, position: Int) = false } 

. . . (capacity), ( ). , ( , ). .

 class DiscardExtraDiceHandMaskRule(hand: Hand) : HandMaskRule(hand) { private val minDiceToDiscard = if (hand.dieCount > hand.capacity) min(hand.dieCount - hand.woundCount, hand.dieCount - hand.capacity) else 0 private val maxDiceToDiscard = hand.dieCount - hand.woundCount override fun checkMask(mask: HandMask) = (mask.positionCount in minDiceToDiscard..maxDiceToDiscard) && (mask.allyPositionCount in 0..hand.allyDieCount) override fun isPositionActive(mask: HandMask, position: Int) = when { mask.checkPosition(position) -> true hand.dieAt(position) == null -> false hand.dieAt(position)!!.type == Die.Type.WOUND -> false mask.positionCount < maxDiceToDiscard -> true else -> false } override fun isAllyPositionActive(mask: HandMask, position: Int) = hand.allyDieAt(position) != null } 

: Hand woundCount , . , . .

.
. , . : , , . , , . , . — , . , . , …

آسف ، نفذت بعيدا. لمحاكاة معركتنا العامة ، ننفذ فئة خاصة.

 class DieBattleCheck(val method: Method, opponent: DiePair? = null) { enum class Method { SUM, AVG_UP, AVG_DOWN, MAX, MIN } private inner class Wrap(val pair: DiePair, var roll: Int) private infix fun DiePair.with(roll: Int) = Wrap(this, roll) private val opponent: Wrap? = opponent?.with(0) private val heroics = ArrayList<Wrap>() var isRolled = false var result: Int? = null val heroPairCount get() = heroics.size fun getOpponentPair() = opponent?.pair fun getOpponentResult() = when { isRolled -> opponent?.roll ?: 0 else -> throw IllegalStateException("Not rolled yet") } fun addHeroPair(pair: DiePair) { if (method == Method.SUM && heroics.size > 0) { pair.modifier = 0 } heroics.add(pair with 0) } fun addHeroPair(die: Die, modifier: Int) = addHeroPair(DiePair(die, modifier)) fun clearHeroPairs() = heroics.clear() fun getHeroPairAt(index: Int) = heroics[index].pair fun getHeroResultAt(index: Int) = when { isRolled -> when { (index in 0 until heroics.size) -> heroics[index].roll else -> 0 } else -> throw IllegalStateException("Not rolled yet") } fun roll() { fun roll(wrap: Wrap) { wrap.roll = wrap.pair.die.roll() } isRolled = true opponent?.let { roll(it) } heroics.forEach { roll(it) } } fun calculateResult() { if (!isRolled) { throw IllegalStateException("Not rolled yet") } val opponentResult = opponent?.let { it.roll + it.pair.modifier } ?: 0 val stats = heroics.map { it.roll + it.pair.modifier } val heroResult = when (method) { DieBattleCheck.Method.SUM -> stats.sum() DieBattleCheck.Method.AVG_UP -> ceil(stats.average()).toInt() DieBattleCheck.Method.AVG_DOWN -> floor(stats.average()).toInt() DieBattleCheck.Method.MAX -> stats.max() ?: 0 DieBattleCheck.Method.MIN -> stats.min() ?: 0 } result = heroResult - opponentResult } } 

, DiePair . . , , (, , ). ( Wrap ). with , -.

( Method ) ( ). . , , ( ).

roll()يستدعي الأسلوب الذي يحمل نفس الاسم لكل مكعب ، ويحفظ النتائج الوسيطة ويحدد حقيقة تنفيذه بعلامة isRolled. يرجى ملاحظة أن النتيجة النهائية للرمي لا يتم حسابها على الفور - هناك طريقة خاصة لذلك calculateResult()، والنتيجة هي كتابة القيمة النهائية للعقار result. لماذا هذا مطلوب؟ للحصول على تأثير كبير. roll()سيتم تشغيل الطريقة عدة مرات ، في كل مرة على وجوه المكعبات سيتم عرض قيم مختلفة (تمامًا كما في الحياة الحقيقية). وفقط عندما تهدأ المكعبات على الطاولة ، نتعلم مصيرنا النتيجة النهائية (الفرق بين قيم مكعبات البطل ومكعبات الخصم). لتخفيف التوتر ، سأقول إن نتيجة 0 ستعتبر نجاحًا في الاختبار.

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

 enum class GamePhase { SCENARIO_START, HERO_TURN_START, HERO_TURN_END, LOCATION_BEFORE_EXPLORATION, LOCATION_ENCOUNTER_STAT, LOCATION_ENCOUNTER_DIVINE, LOCATION_AFTER_EXPLORATION, GAME_LOSS } 

, , . changePhaseX() , X — . , .

.
. - — , ? .

 enum class StatusMessage { EMPTY, CHOOSE_DICE_PERFORM_CHECK, END_OF_TURN_DISCARD_EXTRA, END_OF_TURN_DISCARD_OPTIONAL, CHOOSE_ACTION_BEFORE_EXPLORATION, CHOOSE_ACTION_AFTER_EXPLORATION, ENCOUNTER_PHYSICAL, ENCOUNTER_SOMATIC, ENCOUNTER_MENTAL, ENCOUNTER_VERBAL, ENCOUNTER_DIVINE, DIE_ACQUIRE_SUCCESS, DIE_ACQUIRE_FAILURE, GAME_LOSS_OUT_OF_TIME } 

, . , ( EMPTY — ), .

.
. , (, , — ). .

 class Action( val type: Type, var isEnabled: Boolean = true, val data: Int = 0 ) { enum class Type { NONE, //Blank type CONFIRM, //Confirm some action CANCEL, //Cancel action HAND_POSITION, //Some position in hand HAND_ALLY_POSITION, //Some ally position in hand EXPLORE_LOCATION, //Explore current location FINISH_TURN, //Finish current turn ACQUIRE, //Acquire (DIVINE) die FORFEIT, //Remove die from game HIDE, //Put die into bag DISCARD, //Put die to discard pile } } 

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

كلاسAction «» - ( ). ( ?), (). , , .

 class ActionList : Iterable<Action> { private val actions = mutableListOf<Action>() val size get() = actions.size fun add(action: Action): ActionList { actions.add(action) return this } fun add(type: Action.Type, enabled: Boolean = true): ActionList { add(Action(type, enabled)) return this } fun addAll(actions: ActionList): ActionList { actions.forEach { add(it) } return this } fun remove(type: Action.Type): ActionList { actions.removeIf { it.type == type } return this } operator fun get(index: Int) = actions[index] operator fun get(type: Action.Type) = actions.find { it.type == type } override fun iterator(): Iterator<Action> = ActionListIterator() private inner class ActionListIterator : Iterator<Action> { private var position = -1 override fun hasNext() = (actions.size > position + 1) override fun next() = actions[++position] } companion object { val EMPTY get() = ActionList() } } 

( ), , ( «» get() — ). Iterator all sorts of crazy shit (, ). EMPTY .

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

 enum class GameScreen { HERO_TURN_START, LOCATION_INTERIOR, GAME_LOSS } 

, . … .

«» «».
— (). , , . - . , .

, GameRenderer , . , , . : «- » — , , , .

 interface GameRenderer { fun drawHeroTurnStart(hero: Hero) fun drawLocationInteriorScreen( location: Location, heroesAtLocation: List<Hero>, timer: Int, currentHero: Hero, battleCheck: DieBattleCheck?, encounteredDie: DiePair?, pickedDice: HandMask, activePositions: HandMask, statusMessage: StatusMessage, actions: ActionList ) fun drawGameLoss(message: StatusMessage) } 

, — .

GameInteractor (, , ...). : , , , - . , (- ), , .

 interface GameInteractor{ fun anyInput() fun pickAction(list: ActionList): Action fun pickDiceFromHand(activePositions: HandMask, actions: ActionList): Action } 

حول الطريقة الأخيرة أكثر قليلا. كما يوحي الاسم ، من دعوة المستخدم لتحديد مكعبات من ناحية ، وتوفير كائن HandMask- أرقام المواضع النشطة. سيستمر تنفيذ الطريقة حتى يتم تحديد بعضها - في هذه الحالة ، ستُرجع الطريقة إجراء من النوع HAND_POSITION(أو HAND_ALLY_POSITIONmda) مع رقم الموضع المحدد في الحقل data. بالإضافة إلى ذلك ، من الممكن تحديد إجراء آخر (على سبيل المثال ، CONFIRMأو CANCEL) من الكائن ActionList. يجب أن تميز تطبيقات طرق الإدخال بين المواقف التي يتم فيها isEnabledتعيين الحقل falseوتجاهل إدخال المستخدم لهذه الإجراءات.

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

,
 class Game( private val renderer: GameRenderer, private val interactor: GameInteractor, private val scenario: Scenario, private val locations: List<Location>, private val heroes: List<Hero>) { private var timer = 0 private var currentHeroIndex = -1 private lateinit var currentHero: Hero private lateinit var currentLocation: Location private val deterrentPile = Pile() private var encounteredDie: DiePair? = null private var battleCheck: DieBattleCheck? = null private val activeHandPositions = HandMask() private val pickedHandPositions = HandMask() private var phase: GamePhase = GamePhase.SCENARIO_START private var screen = GameScreen.SCENARIO_INTRO private var statusMessage = StatusMessage.EMPTY private var actions: ActionList = ActionList.EMPTY fun start() { if (heroes.isEmpty()) throw IllegalStateException("Heroes list is empty!") if (locations.isEmpty()) throw IllegalStateException("Location list is empty!") heroes.forEach { it.isAlive = true } timer = scenario.initialTimer //Draw initial hand for each hero heroes.forEach(::drawInitialHand) //First hero turn currentHeroIndex = -1 changePhaseHeroTurnStart() processCycle() } private fun drawInitialHand(hero: Hero) { val hand = hero.hand val favoredDie = hero.bag.drawOfType(hero.favoredDieType) hand.addDie(favoredDie!!) refillHeroHand(hero, false) } private fun refillHeroHand(hero: Hero, redrawScreen: Boolean = true) { val hand = hero.hand while (hand.dieCount < hand.capacity && hero.bag.size > 0) { val die = hero.bag.draw() hand.addDie(die) if (redrawScreen) { Audio.playSound(Sound.DIE_DRAW) drawScreen() Thread.sleep(500) } } } private fun changePhaseHeroTurnEnd() { battleCheck = null encounteredDie = null phase = GamePhase.HERO_TURN_END //Discard extra dice (or optional dice) val hand = currentHero.hand pickedHandPositions.clear() activeHandPositions.clear() val allowCancel = if (hand.dieCount > hand.capacity) { statusMessage = StatusMessage.END_OF_TURN_DISCARD_EXTRA false } else { statusMessage = StatusMessage.END_OF_TURN_DISCARD_OPTIONAL true } val result = pickDiceFromHand(DiscardExtraDiceHandMaskRule(hand), allowCancel) statusMessage = StatusMessage.EMPTY actions = ActionList.EMPTY if (result) { val discardDice = collectPickedDice(hand) val discardAllyDice = collectPickedAllyDice(hand) pickedHandPositions.clear() (discardDice + discardAllyDice).forEach { die -> Audio.playSound(Sound.DIE_DISCARD) currentHero.discardDieFromHand(die) drawScreen() Thread.sleep(500) } } pickedHandPositions.clear() //Replenish hand refillHeroHand(currentHero) changePhaseHeroTurnStart() } private fun changePhaseHeroTurnStart() { phase = GamePhase.HERO_TURN_START screen = GameScreen.HERO_TURN_START //Tick timer timer-- if (timer < 0) { changePhaseGameLost(StatusMessage.GAME_LOSS_OUT_OF_TIME) return } //Pick next hero do { currentHeroIndex = ++currentHeroIndex % heroes.size currentHero = heroes[currentHeroIndex] } while (!currentHero.isAlive) currentLocation = locations[0] //Setup Audio.playMusic(Music.SCENARIO_MUSIC_1) Audio.playSound(Sound.TURN_START) } private fun changePhaseLocationBeforeExploration() { phase = GamePhase.LOCATION_BEFORE_EXPLORATION screen = GameScreen.LOCATION_INTERIOR encounteredDie = null battleCheck = null pickedHandPositions.clear() activeHandPositions.clear() statusMessage = StatusMessage.CHOOSE_ACTION_BEFORE_EXPLORATION actions = ActionList() actions.add(Action.Type.EXPLORE_LOCATION, checkLocationCanBeExplored(currentLocation)) actions.add(Action.Type.FINISH_TURN) } private fun changePhaseLocationEncounterStatDie() { Audio.playSound(Sound.ENCOUNTER_STAT) phase = GamePhase.LOCATION_ENCOUNTER_STAT screen = GameScreen.LOCATION_INTERIOR battleCheck = null pickedHandPositions.clear() activeHandPositions.clear() statusMessage = when (encounteredDie!!.die.type) { Die.Type.PHYSICAL -> StatusMessage.ENCOUNTER_PHYSICAL Die.Type.SOMATIC -> StatusMessage.ENCOUNTER_SOMATIC Die.Type.MENTAL -> StatusMessage.ENCOUNTER_MENTAL Die.Type.VERBAL -> StatusMessage.ENCOUNTER_VERBAL else -> throw AssertionError("Should not happen") } val canAttemptCheck = checkHeroCanAttemptStatCheck(currentHero, encounteredDie!!.die.type) actions = ActionList() actions.add(Action.Type.HIDE, canAttemptCheck) actions.add(Action.Type.DISCARD, canAttemptCheck) actions.add(Action.Type.FORFEIT) } private fun changePhaseLocationEncounterDivineDie() { Audio.playSound(Sound.ENCOUNTER_DIVINE) phase = GamePhase.LOCATION_ENCOUNTER_DIVINE screen = GameScreen.LOCATION_INTERIOR battleCheck = null pickedHandPositions.clear() activeHandPositions.clear() statusMessage = StatusMessage.ENCOUNTER_DIVINE actions = ActionList() actions.add(Action.Type.ACQUIRE, checkHeroCanAcquireDie(currentHero, Die.Type.DIVINE)) actions.add(Action.Type.FORFEIT) } private fun changePhaseLocationAfterExploration() { phase = GamePhase.LOCATION_AFTER_EXPLORATION screen = GameScreen.LOCATION_INTERIOR encounteredDie = null battleCheck = null pickedHandPositions.clear() activeHandPositions.clear() statusMessage = StatusMessage.CHOOSE_ACTION_AFTER_EXPLORATION actions = ActionList() actions.add(Action.Type.FINISH_TURN) } private fun changePhaseGameLost(message: StatusMessage) { Audio.stopMusic() Audio.playSound(Sound.GAME_LOSS) phase = GamePhase.GAME_LOSS screen = GameScreen.GAME_LOSS statusMessage = message } private fun pickDiceFromHand(rule: HandMaskRule, allowCancel: Boolean = true, onEachLoop: (() -> Unit)? = null): Boolean { //Preparations pickedHandPositions.clear() actions = ActionList().add(Action.Type.CONFIRM, false) if (allowCancel) { actions.add(Action.Type.CANCEL) } val hand = rule.hand while (true) { //Recurring action onEachLoop?.invoke() //Define success condition val canProceed = rule.checkMask(pickedHandPositions) actions[Action.Type.CONFIRM]?.isEnabled = canProceed //Prepare active hand commands activeHandPositions.clear() (0 until hand.dieCount) .filter { rule.isPositionActive(pickedHandPositions, it) } .forEach { activeHandPositions.addPosition(it) } (0 until hand.allyDieCount) .filter { rule.isAllyPositionActive(pickedHandPositions, it) } .forEach { activeHandPositions.addAllyPosition(it) } //Draw current phase drawScreen() //Process interaction result val result = interactor.pickDiceFromHand(activeHandPositions, actions) when (result.type) { Action.Type.CONFIRM -> if (canProceed) { activeHandPositions.clear() return true } Action.Type.CANCEL -> if (allowCancel) { activeHandPositions.clear() pickedHandPositions.clear() return false } Action.Type.HAND_POSITION -> { Audio.playSound(Sound.DIE_PICK) pickedHandPositions.switchPosition(result.data) } Action.Type.HAND_ALLY_POSITION -> { Audio.playSound(Sound.DIE_PICK) pickedHandPositions.switchAllyPosition(result.data) } else -> throw AssertionError("Should not happen") } } } private fun collectPickedDice(hand: Hand) = (0 until hand.dieCount) .filter(pickedHandPositions::checkPosition) .mapNotNull(hand::dieAt) private fun collectPickedAllyDice(hand: Hand) = (0 until hand.allyDieCount) .filter(pickedHandPositions::checkAllyPosition) .mapNotNull(hand::allyDieAt) private fun performStatDieAcquireCheck(shouldDiscard: Boolean): Boolean { //Prepare check battleCheck = DieBattleCheck(DieBattleCheck.Method.SUM, encounteredDie) pickedHandPositions.clear() statusMessage = StatusMessage.CHOOSE_DICE_PERFORM_CHECK val hand = currentHero.hand //Try to pick dice from performer's hand if (!pickDiceFromHand(StatDieAcquireHandMaskRule(currentHero.hand, encounteredDie!!.die.type), true) { battleCheck!!.clearHeroPairs() (collectPickedDice(hand) + collectPickedAllyDice(hand)) .map { DiePair(it, if (shouldDiscard) 1 else 0) } .forEach(battleCheck!!::addHeroPair) }) { battleCheck = null pickedHandPositions.clear() return false } //Remove dice from hand collectPickedDice(hand).forEach { hand.removeDie(it) } collectPickedAllyDice(hand).forEach { hand.removeDie(it) } pickedHandPositions.clear() //Perform check Audio.playSound(Sound.BATTLE_CHECK_ROLL) for (i in 0..7) { battleCheck!!.roll() drawScreen() Thread.sleep(100) } battleCheck!!.calculateResult() val result = battleCheck?.result ?: -1 val success = result >= 0 //Process dice which participated in the check (0 until battleCheck!!.heroPairCount) .map(battleCheck!!::getHeroPairAt) .map(DiePair::die) .forEach { d -> if (d.type === Die.Type.DIVINE) { currentHero.hand.removeDie(d) deterrentPile.put(d) } else { if (shouldDiscard) { currentHero.discardDieFromHand(d) } else { currentHero.hideDieFromHand(d) } } } //Show message to user Audio.playSound(if (success) Sound.BATTLE_CHECK_SUCCESS else Sound.BATTLE_CHECK_FAILURE) statusMessage = if (success) StatusMessage.DIE_ACQUIRE_SUCCESS else StatusMessage.DIE_ACQUIRE_FAILURE actions = ActionList.EMPTY drawScreen() interactor.anyInput() //Clean up battleCheck = null //Resolve consequences of the check if (success) { Audio.playSound(Sound.DIE_DRAW) currentHero.hand.addDie(encounteredDie!!.die) } return true } private fun processCycle() { while (true) { drawScreen() when (phase) { GamePhase.HERO_TURN_START -> { interactor.anyInput() changePhaseLocationBeforeExploration() } GamePhase.GAME_LOSS -> { interactor.anyInput() return } GamePhase.LOCATION_BEFORE_EXPLORATION -> when (interactor.pickAction(actions).type) { Action.Type.EXPLORE_LOCATION -> { val die = currentLocation.bag.draw() encounteredDie = DiePair(die, 0) when (die.type) { Die.Type.PHYSICAL, Die.Type.SOMATIC, Die.Type.MENTAL, Die.Type.VERBAL -> changePhaseLocationEncounterStatDie() Die.Type.DIVINE -> changePhaseLocationEncounterDivineDie() else -> TODO("Others") } } Action.Type.FINISH_TURN -> changePhaseHeroTurnEnd() else -> throw AssertionError("Should not happen") } GamePhase.LOCATION_ENCOUNTER_STAT -> { val type = interactor.pickAction(actions).type when (type) { Action.Type.DISCARD, Action.Type.HIDE -> { performStatDieAcquireCheck(type === Action.Type.DISCARD) changePhaseLocationAfterExploration() } Action.Type.FORFEIT -> { Audio.playSound(Sound.DIE_REMOVE) changePhaseLocationAfterExploration() } else -> throw AssertionError("Should not happen") } } GamePhase.LOCATION_ENCOUNTER_DIVINE -> when (interactor.pickAction(actions).type) { Action.Type.ACQUIRE -> { Audio.playSound(Sound.DIE_DRAW) currentHero.hand.addDie(encounteredDie!!.die) changePhaseLocationAfterExploration() } Action.Type.FORFEIT -> { Audio.playSound(Sound.DIE_REMOVE) changePhaseLocationAfterExploration() } else -> throw AssertionError("Should not happen") } GamePhase.LOCATION_AFTER_EXPLORATION -> when (interactor.pickAction(actions).type) { Action.Type.FINISH_TURN -> changePhaseHeroTurnEnd() else -> throw AssertionError("Should not happen") } else -> throw AssertionError("Should not happen") } } } private fun drawScreen() { when (screen) { GameScreen.HERO_TURN_START -> renderer.drawHeroTurnStart(currentHero) GameScreen.LOCATION_INTERIOR -> renderer.drawLocationInteriorScreen(currentLocation, heroes, timer, currentHero, battleCheck, encounteredDie, null, pickedHandPositions, activeHandPositions, statusMessage, actions) GameScreen.GAME_LOSS -> renderer.drawGameLoss(statusMessage) } } private fun checkLocationCanBeExplored(location: Location) = location.isOpen && location.bag.size > 0 private fun checkHeroCanAttemptStatCheck(hero: Hero, type: Die.Type): Boolean { return hero.isAlive && SingleDieHandFilter(type).test(hero.hand) } private fun checkHeroCanAcquireDie(hero: Hero, type: Die.Type): Boolean { if (!hero.isAlive) { return false } return when (type) { Die.Type.ALLY -> hero.hand.allyDieCount < MAX_HAND_ALLY_SIZE else -> hero.hand.dieCount < MAX_HAND_SIZE } } } 

start() — . , , , . , . drawInitialHand() (, , drawOfType() Bag , , ). refillHeroHand() ( redrawScreen ): ( ), , , .

, changePhase , — , . actions , .

pickDiceFromHand() . HandMaskRule , . ( allowCancel ), onEachLoop , ( ). collectPickedDice() collectPickedAllyDice() .

performStatDieAcquireCheck() . DieBattleCheck . pickDiceFromHand() ( «» DieBattleCheck ). , «» — ( ), . . ( ), ( shouldDiscard = true ), ( shouldDiscard = false ).

processCycle() ( ), , , — . drawScreen() GameRenderer ( screen ), .

: checkLocationCanBeExplored() , checkHeroCanAttemptStatCheck() checkHeroCanAcquireDie() . , . Audio , . — .

, ( , ):


, (-). , .

.


— . , GameRenderer , , . , . :

1.


2.


3.


, , , Java-, prinltn() . .

ANSI . , : /, , . , — . — , . - , , Jansi :

 <dependency> <groupId>org.fusesource.jansi</groupId> <artifactId>jansi</artifactId> <version>1.17.1</version> <scope>compile</scope> </dependency> 

. Ansi ( Ansi.ansi() ) , . StringBuilder ' — , . :

  • a() — ;
  • cursor() — ;
  • eraseLine() — - ;
  • eraseScreen() — ;
  • fg(), bg(), fgBright(), bgBright() — — , ;
  • reset() — , .

ConsoleRenderer , . :

 abstract class ConsoleRenderer() { protected lateinit var ansi: Ansi init { AnsiConsole.systemInstall() clearScreen() resetAnsi() } private fun resetAnsi() { ansi = Ansi.ansi() } fun clearScreen() { print(Ansi.ansi().eraseScreen(Ansi.Erase.ALL).cursor(1, 1)) } protected fun render() { print(ansi.toString()) resetAnsi() } } 

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

لنبدأ بالأحجام. وحدة التحكم القياسية لمعظم المحطات 80 × 24 في الحجم. نلاحظ هذه الحقيقة مع اثنين من الثوابت CONSOLE_WIDTHو CONSOLE_HEIGHT. لن يتم ربطنا بقيم محددة وسنحاول جعل التصميم مطاطيًا قدر الإمكان (مثل على الويب). يبدأ ترقيم الإحداثيات بواحد ، الإحداثي الأول عبارة عن صف ، والثاني عمود. مع العلم كل هذا ، نكتب طريقة فائدةdrawHorizontalLine() لتعبئة السلسلة المحددة بالحرف المحدد.

 protected fun drawHorizontalLine(offsetY: Int, filler: Char) { ansi.cursor(offsetY, 1) (1..CONSOLE_WIDTH).forEach { ansi.a(filler) } //for (i in 1..CONSOLE_WIDTH) { ansi.a(filler) } } 

مرة أخرى ، أذكرك بأن استدعاء الأوامر a()أو cursor()لا يؤدي إلى أي تأثير فوري ، ولكن يضيف فقط Ansiتسلسل الأوامر المطابق إلى الكائن . فقط عندما يتم إرسال هذه التسلسلات للطباعة ، سنراها على الشاشة.

لا يوجد فرق جوهري بين استخدام الدورة الكلاسيكية forوالنهج الوظيفي مع ClosedRangeو forEach{}- كل مطور يقرر بنفسه ما هو أكثر ملاءمة له. ومع ذلك ، سأستمر في خداع رؤوسك بالوظيفة ، وذلك ببساطة لأنني قرد يعشق كل شيء بين قوسين جديدين ولامعين لا يتم لفهما بخط جديد والرمز يبدو أكثر إحكاما.

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

 protected fun drawBlankLine(offsetY: Int, drawBorders: Boolean = true) { ansi.cursor(offsetY, 1) if (drawBorders) { ansi.a('│') (2 until CONSOLE_WIDTH).forEach { ansi.a(' ') } ansi.a('│') } else { ansi.eraseLine(Ansi.Erase.ALL) } } 

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


ثم ، كما هو الحال في minecraft ، فإن الإمكانيات محدودة فقط بحدود خيالك. وحجم الشاشة.

 protected fun drawCenteredCaption(offsetY: Int, text: String, color: Color, drawBorders: Boolean = true) { val center = (CONSOLE_WIDTH - text.length) / 2 ansi.cursor(offsetY, 1) ansi.a(if (drawBorders) '│' else ' ') (2 until center).forEach { ansi.a(' ') } ansi.color(color).a(text).reset() (text.length + center until CONSOLE_WIDTH).forEach { ansi.a(' ') } ansi.a(if (drawBorders) '│' else ' ') } 

. Ansi Color (, , , , , , , ), fg()/bg() fgBright()/bgBright() — , , — - ( ). - ( - ):

 protected enum class Color { BLACK, DARK_BLUE, DARK_GREEN, DARK_CYAN, DARK_RED, DARK_MAGENTA, DARK_YELLOW, LIGHT_GRAY, DARK_GRAY, LIGHT_BLUE, LIGHT_GREEN, LIGHT_CYAN, LIGHT_RED, LIGHT_MAGENTA, LIGHT_YELLOW, WHITE } protected fun Ansi.color(color: Color?): Ansi = when (color) { Color.BLACK -> fgBlack() Color.DARK_BLUE -> fgBlue() Color.DARK_GREEN -> fgGreen() Color.DARK_CYAN -> fgCyan() Color.DARK_RED -> fgRed() Color.DARK_MAGENTA -> fgMagenta() Color.DARK_YELLOW -> fgYellow() Color.LIGHT_GRAY -> fg(Ansi.Color.WHITE) Color.DARK_GRAY -> fgBrightBlack() Color.LIGHT_BLUE -> fgBrightBlue() Color.LIGHT_GREEN -> fgBrightGreen() Color.LIGHT_CYAN -> fgBrightCyan() Color.LIGHT_RED -> fgBrightRed() Color.LIGHT_MAGENTA -> fgBrightMagenta() Color.LIGHT_YELLOW -> fgBrightYellow() Color.WHITE -> fgBright(Ansi.Color.WHITE) else -> this } protected fun Ansi.background(color: Color?): Ansi = when (color) { Color.BLACK -> ansi.bg(Ansi.Color.BLACK) Color.DARK_BLUE -> ansi.bg(Ansi.Color.BLUE) Color.DARK_GREEN -> ansi.bgGreen() Color.DARK_CYAN -> ansi.bg(Ansi.Color.CYAN) Color.DARK_RED -> ansi.bgRed() Color.DARK_MAGENTA -> ansi.bgMagenta() Color.DARK_YELLOW -> ansi.bgYellow() Color.LIGHT_GRAY -> ansi.bg(Ansi.Color.WHITE) Color.DARK_GRAY -> ansi.bgBright(Ansi.Color.BLACK) Color.LIGHT_BLUE -> ansi.bgBright(Ansi.Color.BLUE) Color.LIGHT_GREEN -> ansi.bgBrightGreen() Color.LIGHT_CYAN -> ansi.bgBright(Ansi.Color.CYAN) Color.LIGHT_RED -> ansi.bgBrightRed() Color.LIGHT_MAGENTA -> ansi.bgBright(Ansi.Color.MAGENTA) Color.LIGHT_YELLOW -> ansi.bgBrightYellow() Color.WHITE -> ansi.bgBright(Ansi.Color.WHITE) else -> this } protected val dieColors = mapOf( Die.Type.PHYSICAL to Color.LIGHT_BLUE, Die.Type.SOMATIC to Color.LIGHT_GREEN, Die.Type.MENTAL to Color.LIGHT_MAGENTA, Die.Type.VERBAL to Color.LIGHT_YELLOW, Die.Type.DIVINE to Color.LIGHT_CYAN, Die.Type.WOUND to Color.DARK_GRAY, Die.Type.ENEMY to Color.DARK_RED, Die.Type.VILLAIN to Color.LIGHT_RED, Die.Type.OBSTACLE to Color.DARK_YELLOW, Die.Type.ALLY to Color.WHITE ) protected val heroColors = mapOf( Hero.Type.BRAWLER to Color.LIGHT_BLUE, Hero.Type.HUNTER to Color.LIGHT_GREEN ) 

16- . , :

?

« , — . ...»

… . . Java java.util.ResourceBundle , .properties . :

 # Game status messages choose_dice_perform_check=Choose dice to perform check: end_of_turn_discard_extra=END OF TURN: Discard extra dice: end_of_turn_discard_optional=END OF TURN: Discard any dice, if needed: choose_action_before_exploration=Choose your action: choose_action_after_exploration=Already explored this turn. Choose what to do now: encounter_physical=Encountered PHYSICAL die. Need to pass respective check or lose this die. encounter_somatic=Encountered SOMATIC die. Need to pass respective check or lose this die. encounter_mental=Encountered MENTAL die. Need to pass respective check or lose this die. encounter_verbal=Encountered VERBAL die. Need to pass respective check or lose this die. encounter_divine=Encountered DIVINE die. Can be acquired automatically (no checks needed): die_acquire_success=You have acquired the die! die_acquire_failure=You have failed to acquire the die. game_loss_out_of_time=You ran out of time # Die types physical=PHYSICAL somatic=SOMATIC mental=MENTAL verbal=VERBAL divine=DIVINE ally=ALLY wound=WOUND enemy=ENEMY villain=VILLAIN obstacle=OBSTACLE # Hero types and descriptions brawler=Brawler hunter=Hunter # Various labels avg=avg bag=Bag bag_size=Bag size class=Class closed=Closed discard=Discard empty=Empty encountered=Encountered fail=Fail hand=Hand heros_turn=%s's turn max=max min=min perform_check=Perform check: pile=Pile received_new_die=Received new die result=Result success=Success sum=sum time=Time total=Total # Action names and descriptions action_confirm_key=ENTER action_confirm_name=Confirm action_cancel_key=ESC action_cancel_name=Cancel action_explore_location_key=E action_explore_location_name=xplore action_finish_turn_key=F action_finish_turn_name=inish action_hide_key=H action_hide_name=ide action_discard_key=D action_discard_name=iscard action_acquire_key=A action_acquire_name=cquire action_leave_key=L action_leave_name=eave action_forfeit_key=F action_forfeit_name=orfeit 

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

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

 interface StringLoader { fun loadString(key: String): String } 

ينتقل المفتاح إلى المدخلات ، الإخراج هو خط معين. التنفيذ واضح ومباشر مثل الواجهة نفسها (افترض أن الملف يقع على طول المسار src/main/resources/text/strings.properties).

 class PropertiesStringLoader() : StringLoader { private val properties = ResourceBundle.getBundle("text.strings") override fun loadString(key: String) = properties.getString(key) ?: "" } 

drawStatusMessage() ( StatusMessage ) drawActionList() ( ActionList ). , .

, ...
 abstract class ConsoleRenderer(private val strings: StringLoader) { protected lateinit var ansi: Ansi init { AnsiConsole.systemInstall() clearScreen() resetAnsi() } protected fun loadString(key: String) = strings.loadString(key) private fun resetAnsi() { ansi = Ansi.ansi() } fun clearScreen() { print(Ansi.ansi().eraseScreen(Ansi.Erase.ALL).cursor(1, 1)) } protected fun render() { ansi.cursor(CONSOLE_HEIGHT, CONSOLE_WIDTH) System.out.print(ansi.toString()) resetAnsi() } protected fun drawBigNumber(offsetX: Int, offsetY: Int, number: Int): Unit = with(ansi) { var currentX = offsetX cursor(offsetY, currentX) val text = number.toString() text.forEach { when (it) { '0' -> { cursor(offsetY, currentX) a(" ███ ") cursor(offsetY + 1, currentX) a("█ █ ") cursor(offsetY + 2, currentX) a("█ █ ") cursor(offsetY + 3, currentX) a("█ █ ") cursor(offsetY + 4, currentX) a(" ███ ") } '1' -> { cursor(offsetY, currentX) a(" █ ") cursor(offsetY + 1, currentX) a(" ██ ") cursor(offsetY + 2, currentX) a("█ █ ") cursor(offsetY + 3, currentX) a(" █ ") cursor(offsetY + 4, currentX) a("█████ ") } '2' -> { cursor(offsetY, currentX) a(" ███ ") cursor(offsetY + 1, currentX) a("█ █ ") cursor(offsetY + 2, currentX) a(" █ ") cursor(offsetY + 3, currentX) a(" █ ") cursor(offsetY + 4, currentX) a("█████ ") } '3' -> { cursor(offsetY, currentX) a("████ ") cursor(offsetY + 1, currentX) a(" █ ") cursor(offsetY + 2, currentX) a(" ██ ") cursor(offsetY + 3, currentX) a(" █ ") cursor(offsetY + 4, currentX) a("████ ") } '4' -> { cursor(offsetY, currentX) a(" █ ") cursor(offsetY + 1, currentX) a(" ██ ") cursor(offsetY + 2, currentX) a(" █ █ ") cursor(offsetY + 3, currentX) a("█████ ") cursor(offsetY + 4, currentX) a(" █ ") } '5' -> { cursor(offsetY, currentX) a("█████ ") cursor(offsetY + 1, currentX) a("█ ") cursor(offsetY + 2, currentX) a("████ ") cursor(offsetY + 3, currentX) a(" █ ") cursor(offsetY + 4, currentX) a("████ ") } '6' -> { cursor(offsetY, currentX) a(" ███ ") cursor(offsetY + 1, currentX) a("█ ") cursor(offsetY + 2, currentX) a("████ ") cursor(offsetY + 3, currentX) a("█ █ ") cursor(offsetY + 4, currentX) a(" ███ ") } '7' -> { cursor(offsetY, currentX) a("█████ ") cursor(offsetY + 1, currentX) a(" █ ") cursor(offsetY + 2, currentX) a(" █ ") cursor(offsetY + 3, currentX) a(" █ ") cursor(offsetY + 4, currentX) a(" █ ") } '8' -> { cursor(offsetY, currentX) a(" ███ ") cursor(offsetY + 1, currentX) a("█ █ ") cursor(offsetY + 2, currentX) a(" ███ ") cursor(offsetY + 3, currentX) a("█ █ ") cursor(offsetY + 4, currentX) a(" ███ ") } '9' -> { cursor(offsetY, currentX) a(" ███ ") cursor(offsetY + 1, currentX) a("█ █ ") cursor(offsetY + 2, currentX) a(" ████ ") cursor(offsetY + 3, currentX) a(" █ ") cursor(offsetY + 4, currentX) a(" ███ ") } } currentX += 6 } } protected fun drawHorizontalLine(offsetY: Int, filler: Char) { ansi.cursor(offsetY, 1) (1..CONSOLE_WIDTH).forEach { ansi.a(filler) } } protected fun drawBlankLine(offsetY: Int, drawBorders: Boolean = true) { ansi.cursor(offsetY, 1) if (drawBorders) { ansi.a('│') (2 until CONSOLE_WIDTH).forEach { ansi.a(' ') } ansi.a('│') } else { ansi.eraseLine(Ansi.Erase.ALL) } } protected fun drawCenteredCaption(offsetY: Int, text: String, color: Color, drawBorders: Boolean = true) { val center = (CONSOLE_WIDTH - text.length) / 2 ansi.cursor(offsetY, 1) ansi.a(if (drawBorders) '│' else ' ') (2 until center).forEach { ansi.a(' ') } ansi.color(color).a(text).reset() (text.length + center until CONSOLE_WIDTH).forEach { ansi.a(' ') } ansi.a(if (drawBorders) '│' else ' ') } protected fun drawStatusMessage(offsetY: Int, message: StatusMessage, drawBorders: Boolean = true) { //Setup val messageText = loadString(message.toString().toLowerCase()) var currentX = 1 val rightBorder = CONSOLE_WIDTH - if (drawBorders) 1 else 0 //Left border ansi.cursor(offsetY, 1) if (drawBorders) { ansi.a('│') currentX++ } ansi.a(' ') currentX++ //Text ansi.a(messageText) currentX += messageText.length //Right border (currentX..rightBorder).forEach { ansi.a(' ') } if (drawBorders) { ansi.a('│') } } protected fun drawActionList(offsetY: Int, actions: ActionList, drawBorders: Boolean = true) { val rightBorder = CONSOLE_WIDTH - if (drawBorders) 1 else 0 var currentX = 1 //Left border ansi.cursor(offsetY, 1) if (drawBorders) { ansi.a('│') currentX++ } ansi.a(' ') currentX++ //List of actions actions.forEach { action -> val key = loadString("action_${action.toString().toLowerCase()}_key") val name = loadString("action_${action.toString().toLowerCase()}_name") val length = key.length + 2 + name.length if (currentX + length >= rightBorder) { (currentX..rightBorder).forEach { ansi.a(' ') } if (drawBorders) { ansi.a('│') } ansi.cursor(offsetY + 1, 1) currentX = 1 if (drawBorders) { ansi.a('│') currentX++ } ansi.a(' ') currentX++ } if (action.isEnabled) { ansi.color(Color.LIGHT_YELLOW) } ansi.a('(').a(key).a(')').reset() ansi.a(name) ansi.a(" ") currentX += length + 2 } //Right border (currentX..rightBorder).forEach { ansi.a(' ') } if (drawBorders) { ansi.a('│') } } protected enum class Color { BLACK, DARK_BLUE, DARK_GREEN, DARK_CYAN, DARK_RED, DARK_MAGENTA, DARK_YELLOW, LIGHT_GRAY, DARK_GRAY, LIGHT_BLUE, LIGHT_GREEN, LIGHT_CYAN, LIGHT_RED, LIGHT_MAGENTA, LIGHT_YELLOW, WHITE } protected fun Ansi.color(color: Color?): Ansi = when (color) { Color.BLACK -> fgBlack() Color.DARK_BLUE -> fgBlue() Color.DARK_GREEN -> fgGreen() Color.DARK_CYAN -> fgCyan() Color.DARK_RED -> fgRed() Color.DARK_MAGENTA -> fgMagenta() Color.DARK_YELLOW -> fgYellow() Color.LIGHT_GRAY -> fg(Ansi.Color.WHITE) Color.DARK_GRAY -> fgBrightBlack() Color.LIGHT_BLUE -> fgBrightBlue() Color.LIGHT_GREEN -> fgBrightGreen() Color.LIGHT_CYAN -> fgBrightCyan() Color.LIGHT_RED -> fgBrightRed() Color.LIGHT_MAGENTA -> fgBrightMagenta() Color.LIGHT_YELLOW -> fgBrightYellow() Color.WHITE -> fgBright(Ansi.Color.WHITE) else -> this } protected fun Ansi.background(color: Color?): Ansi = when (color) { Color.BLACK -> ansi.bg(Ansi.Color.BLACK) Color.DARK_BLUE -> ansi.bg(Ansi.Color.BLUE) Color.DARK_GREEN -> ansi.bgGreen() Color.DARK_CYAN -> ansi.bg(Ansi.Color.CYAN) Color.DARK_RED -> ansi.bgRed() Color.DARK_MAGENTA -> ansi.bgMagenta() Color.DARK_YELLOW -> ansi.bgYellow() Color.LIGHT_GRAY -> ansi.bg(Ansi.Color.WHITE) Color.DARK_GRAY -> ansi.bgBright(Ansi.Color.BLACK) Color.LIGHT_BLUE -> ansi.bgBright(Ansi.Color.BLUE) Color.LIGHT_GREEN -> ansi.bgBrightGreen() Color.LIGHT_CYAN -> ansi.bgBright(Ansi.Color.CYAN) Color.LIGHT_RED -> ansi.bgBrightRed() Color.LIGHT_MAGENTA -> ansi.bgBright(Ansi.Color.MAGENTA) Color.LIGHT_YELLOW -> ansi.bgBrightYellow() Color.WHITE -> ansi.bgBright(Ansi.Color.WHITE) else -> this } protected val dieColors = mapOf( Die.Type.PHYSICAL to Color.LIGHT_BLUE, Die.Type.SOMATIC to Color.LIGHT_GREEN, Die.Type.MENTAL to Color.LIGHT_MAGENTA, Die.Type.VERBAL to Color.LIGHT_YELLOW, Die.Type.DIVINE to Color.LIGHT_CYAN, Die.Type.WOUND to Color.DARK_GRAY, Die.Type.ENEMY to Color.DARK_RED, Die.Type.VILLAIN to Color.LIGHT_RED, Die.Type.OBSTACLE to Color.DARK_YELLOW, Die.Type.ALLY to Color.WHITE ) protected val heroColors = mapOf( Hero.Type.BRAWLER to Color.LIGHT_BLUE, Hero.Type.HUNTER to Color.LIGHT_GREEN ) protected open fun shortcut(index: Int) = "1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ"[index] } 

, ? , GameRenderer .



, :

 override fun drawGameLoss(message: StatusMessage) { val centerY = CONSOLE_HEIGHT / 2 (1 until centerY).forEach { drawBlankLine(it, false) } val data = loadString(message.toString().toLowerCase()).toUpperCase() drawCenteredCaption(centerY, data, LIGHT_RED, false) (centerY + 1..CONSOLE_HEIGHT).forEach { drawBlankLine(it, false) } render() } 

, ( data ), ( drawCenteredCaption() ). . , - , — clearScreen() , , , . , , . : , , ( ). , بقية الشاشة بأحرف فارغة (بحيث لا تظل القطع الأثرية من التقديم الآخر عليها). وهذه المهمة ليست بهذه البساطة.

الطريقة التالية تتبع هذا المبدأ:

 override fun drawHeroTurnStart(hero: Hero) { val centerY = (CONSOLE_HEIGHT - 5) / 2 (1 until centerY).forEach { drawBlankLine(it, false) } ansi.color(heroColors[hero.type]) drawHorizontalLine(centerY, '─') drawHorizontalLine(centerY + 4, '─') ansi.reset() ansi.cursor(centerY + 1, 1).eraseLine() ansi.cursor(centerY + 3, 1).eraseLine() ansi.cursor(centerY + 2, 1) val text = String.format(loadString("heros_turn"), hero.name.toUpperCase()) val index = text.indexOf(hero.name.toUpperCase()) val center = (CONSOLE_WIDTH - text.length) / 2 ansi.cursor(centerY + 2, center) ansi.eraseLine(Ansi.Erase.BACKWARD) ansi.a(text.substring(0, index)) ansi.color(heroColors[hero.type]).a(hero.name.toUpperCase()).reset() ansi.a(text.substring(index + hero.name.length)) ansi.eraseLine(Ansi.Erase.FORWARD) (centerY + 5..CONSOLE_HEIGHT).forEach { drawBlankLine(it, false) } render() } 

( ). , . , - .

, drawLocationInteriorScreen() . , . , ( ). : , , :

1.


2.


3. عرض نتائج الاختبار


لذلك ، إليك نصيحتي العظيمة لك: لا تشق كل الشفرة في طريقة واحدة. قسم التنفيذ إلى عدة طرق (حتى لو كان سيتم استدعاء كل منها مرة واحدة فقط). حسنا ، لا تنسى عن "المطاط".

إذا بدأ التموج في عينيك ، امض لبضع ثوان - وهذا من شأنه أن يساعد
 class ConsoleGameRenderer(loader: StringLoader) : ConsoleRenderer(loader), GameRenderer { private fun drawLocationTopPanel(location: Location, heroesAtLocation: List<Hero>, currentHero: Hero, timer: Int) { val closedString = loadString("closed").toLowerCase() val timeString = loadString("time") val locationName = location.name.toString().toUpperCase() val separatorX1 = locationName.length + if (location.isOpen) { 6 + if (location.bag.size >= 10) 2 else 1 } else { closedString.length + 7 } val separatorX2 = CONSOLE_WIDTH - timeString.length - 6 - if (timer >= 10) 1 else 0 //Top border ansi.cursor(1, 1) ansi.a('┌') (2 until CONSOLE_WIDTH).forEach { ansi.a(if (it == separatorX1 || it == separatorX2) '┬' else '─') } ansi.a('┐') //Center row ansi.cursor(2, 1) ansi.a("│ ") if (location.isOpen) { ansi.color(WHITE).a(locationName).reset() ansi.a(": ").a(location.bag.size) } else { ansi.a(locationName).reset() ansi.color(DARK_GRAY).a(" (").a(closedString).a(')').reset() } ansi.a(" │") var currentX = separatorX1 + 2 heroesAtLocation.forEach { hero -> ansi.a(' ') ansi.color(heroColors[hero.type]) ansi.a(if (hero === currentHero) '☻' else '').reset() currentX += 2 } (currentX..separatorX2).forEach { ansi.a(' ') } ansi.a("│ ").a(timeString).a(": ") when { timer <= 5 -> ansi.color(LIGHT_RED) timer <= 15 -> ansi.color(LIGHT_YELLOW) else -> ansi.color(LIGHT_GREEN) } ansi.bold().a(timer).reset().a(" │") //Bottom border ansi.cursor(3, 1) ansi.a('├') (2 until CONSOLE_WIDTH).forEach { ansi.a(if (it == separatorX1 || it == separatorX2) '┴' else '─') } ansi.a('┤') } private fun drawLocationHeroPanel(offsetY: Int, hero: Hero) { val bagString = loadString("bag").toUpperCase() val discardString = loadString("discard").toUpperCase() val separatorX1 = hero.name.length + 4 val separatorX3 = CONSOLE_WIDTH - discardString.length - 6 - if (hero.discardPile.size >= 10) 1 else 0 val separatorX2 = separatorX3 - bagString.length - 6 - if (hero.bag.size >= 10) 1 else 0 //Top border ansi.cursor(offsetY, 1) ansi.a('├') (2 until CONSOLE_WIDTH).forEach { ansi.a(if (it == separatorX1 || it == separatorX2 || it == separatorX3) '┬' else '─') } ansi.a('┤') //Center row ansi.cursor(offsetY + 1, 1) ansi.a("│ ") ansi.color(heroColors[hero.type]).a(hero.name.toUpperCase()).reset() ansi.a(" │") val currentX = separatorX1 + 1 (currentX until separatorX2).forEach { ansi.a(' ') } ansi.a("│ ").a(bagString).a(": ") when { hero.bag.size <= hero.hand.capacity -> ansi.color(LIGHT_RED) else -> ansi.color(LIGHT_YELLOW) } ansi.a(hero.bag.size).reset() ansi.a(" │ ").a(discardString).a(": ") ansi.a(hero.discardPile.size) ansi.a(" │") //Bottom border ansi.cursor(offsetY + 2, 1) ansi.a('├') (2 until CONSOLE_WIDTH).forEach { ansi.a(if (it == separatorX1 || it == separatorX2 || it == separatorX3) '┴' else '─') } ansi.a('┤') } private fun drawDieSize(die: Die, checked: Boolean = false) { when { checked -> ansi.background(dieColors[die.type]).color(BLACK) else -> ansi.color(dieColors[die.type]) } ansi.a(die.toString()).reset() } private fun drawDieFrameSmall(offsetX: Int, offsetY: Int, longDieSize: Boolean) { //Top border ansi.cursor(offsetY, offsetX) ansi.a('╔') (0 until if (longDieSize) 5 else 4).forEach { ansi.a('═') } ansi.a('╗') //Left border ansi.cursor(offsetY + 1, offsetX) ansi.a("║ ") //Bottom border ansi.cursor(offsetY + 2, offsetX) ansi.a("╚") (0 until if (longDieSize) 5 else 4).forEach { ansi.a('═') } ansi.a('╝') //Right border ansi.cursor(offsetY + 1, offsetX + if (longDieSize) 6 else 5) ansi.a('║') } private fun drawDieSmall(offsetX: Int, offsetY: Int, pair: DiePair, rollResult: Int? = null) { ansi.color(dieColors[pair.die.type]) val longDieSize = pair.die.size >= 10 drawDieFrameSmall(offsetX, offsetY, longDieSize) //Roll result or die size ansi.cursor(offsetY + 1, offsetX + 1) if (rollResult != null) { ansi.a(String.format(" %2d %s", rollResult, if (longDieSize) " " else "")) } else { ansi.a(' ').a(pair.die.toString()).a(' ') } //Draw modifier ansi.cursor(offsetY + 3, offsetX) val modString = if (pair.modifier == 0) "" else String.format("%+d", pair.modifier) val frameLength = 4 + if (longDieSize) 3 else 2 var spaces = (frameLength - modString.length) / 2 (0 until spaces).forEach { ansi.a(' ') } ansi.a(modString) spaces = frameLength - spaces - modString.length (0 until spaces).forEach { ansi.a(' ') } ansi.reset() } private fun drawDieFrameBig(offsetX: Int, offsetY: Int, longDieSize: Boolean) { //Top border ansi.cursor(offsetY, offsetX) ansi.a('╔') (0 until if (longDieSize) 3 else 2).forEach { ansi.a("══════") } ansi.a("═╗") //Left border (1..5).forEach { ansi.cursor(offsetY + it, offsetX) ansi.a('║') } //Bottom border ansi.cursor(offsetY + 6, offsetX) ansi.a('╚') (0 until if (longDieSize) 3 else 2).forEach { ansi.a("══════") } ansi.a("═╝") //Right border val currentX = offsetX + if (longDieSize) 20 else 14 (1..5).forEach { ansi.cursor(offsetY + it, currentX) ansi.a('║') } } private fun drawDieSizeBig(offsetX: Int, offsetY: Int, pair: DiePair) { ansi.color(dieColors[pair.die.type]) val longDieSize = pair.die.size >= 10 drawDieFrameBig(offsetX, offsetY, longDieSize) //Die size ansi.cursor(offsetY + 1, offsetX + 1) ansi.a(" ████ ") ansi.cursor(offsetY + 2, offsetX + 1) ansi.a(" █ █ ") ansi.cursor(offsetY + 3, offsetX + 1) ansi.a(" █ █ ") ansi.cursor(offsetY + 4, offsetX + 1) ansi.a(" █ █ ") ansi.cursor(offsetY + 5, offsetX + 1) ansi.a(" ████ ") drawBigNumber(offsetX + 8, offsetY + 1, pair.die.size) //Draw modifier ansi.cursor(offsetY + 7, offsetX) val modString = if (pair.modifier == 0) "" else String.format("%+d", pair.modifier) val frameLength = 4 + 6 * if (longDieSize) 3 else 2 var spaces = (frameLength - modString.length) / 2 (0 until spaces).forEach { ansi.a(' ') } ansi.a(modString) spaces = frameLength - spaces - modString.length - 1 (0 until spaces).forEach { ansi.a(' ') } ansi.reset() } private fun drawBattleCheck(offsetY: Int, battleCheck: DieBattleCheck) { val performCheck = loadString("perform_check") var currentX = 4 var currentY = offsetY //Top message ansi.cursor(offsetY, 1) ansi.a("│ ").a(performCheck) (performCheck.length + 4 until CONSOLE_WIDTH).forEach { ansi.a(' ') } ansi.a('│') //Left border (1..4).forEach { ansi.cursor(offsetY + it, 1) ansi.a("│ ") } //Opponent var opponentWidth = 0 var vsWidth = 0 (battleCheck.getOpponentPair())?.let { //Die if (battleCheck.isRolled) { drawDieSmall(4, offsetY + 1, it, battleCheck.getOpponentResult()) } else { drawDieSmall(4, offsetY + 1, it) } opponentWidth = 4 + if (it.die.size >= 10) 3 else 2 currentX += opponentWidth //VS ansi.cursor(currentY + 1, currentX) ansi.a(" ") ansi.cursor(currentY + 2, currentX) ansi.color(LIGHT_YELLOW).a(" VS ").reset() ansi.cursor(currentY + 3, currentX) ansi.a(" ") ansi.cursor(currentY + 4, currentX) ansi.a(" ") vsWidth = 4 currentX += vsWidth } //Clear below for (row in currentY + 5..currentY + 8) { ansi.cursor(row, 1) ansi.a('│') (2 until currentX).forEach { ansi.a(' ') } } //Dice for (index in 0 until battleCheck.heroPairCount) { if (index > 0) { ansi.cursor(currentY + 1, currentX) ansi.a(" ") ansi.cursor(currentY + 2, currentX) ansi.a(if (battleCheck.method == DieBattleCheck.Method.SUM) " + " else " / ").reset() ansi.cursor(currentY + 3, currentX) ansi.a(" ") ansi.cursor(currentY + 4, currentX) ansi.a(" ") currentX += 3 } val pair = battleCheck.getHeroPairAt(index) val width = 4 + if (pair.die.size >= 10) 3 else 2 if (currentX + width + 3 > CONSOLE_WIDTH) { //Out of space for (row in currentY + 1..currentY + 4) { ansi.cursor(row, currentX) (currentX until CONSOLE_WIDTH).forEach { ansi.a(' ') } ansi.a('│') } currentY += 4 currentX = 4 + vsWidth + opponentWidth } if (battleCheck.isRolled) { drawDieSmall(currentX, currentY + 1, pair, battleCheck.getHeroResultAt(index)) } else { drawDieSmall(currentX, currentY + 1, pair) } currentX += width } //Clear the rest (currentY + 1..currentY + 4).forEach { row -> ansi.cursor(row, currentX) (currentX until CONSOLE_WIDTH).forEach { ansi.a(' ') } ansi.a('│') } if (currentY == offsetY) { //Still on the first line currentX = 4 + vsWidth + opponentWidth (currentY + 5..currentY + 8).forEach { row -> ansi.cursor(row, currentX) (currentX until CONSOLE_WIDTH).forEach { ansi.a(' ') } ansi.a('│') } } //Draw result (battleCheck.result)?.let { r -> val frameTopY = offsetY + 5 val result = String.format("%+d", r) val message = loadString(if (r >= 0) "success" else "fail").toUpperCase() val color = if (r >= 0) DARK_GREEN else DARK_RED //Frame ansi.color(color) drawHorizontalLine(frameTopY, '▒') drawHorizontalLine(frameTopY + 3, '▒') ansi.cursor(frameTopY + 1, 1).a("▒▒") ansi.cursor(frameTopY + 1, CONSOLE_WIDTH - 1).a("▒▒") ansi.cursor(frameTopY + 2, 1).a("▒▒") ansi.cursor(frameTopY + 2, CONSOLE_WIDTH - 1).a("▒▒") ansi.reset() //Top message val resultString = loadString("result") var center = (CONSOLE_WIDTH - result.length - resultString.length - 2) / 2 ansi.cursor(frameTopY + 1, 3) (3 until center).forEach { ansi.a(' ') } ansi.a(resultString).a(": ") ansi.color(color).a(result).reset() (center + result.length + resultString.length + 2 until CONSOLE_WIDTH - 1).forEach { ansi.a(' ') } //Bottom message center = (CONSOLE_WIDTH - message.length) / 2 ansi.cursor(frameTopY + 2, 3) (3 until center).forEach { ansi.a(' ') } ansi.color(color).a(message).reset() (center + message.length until CONSOLE_WIDTH - 1).forEach { ansi.a(' ') } } } private fun drawExplorationResult(offsetY: Int, pair: DiePair) { val encountered = loadString("encountered") ansi.cursor(offsetY, 1) ansi.a("│ ").a(encountered).a(':') (encountered.length + 5 until CONSOLE_WIDTH).forEach { ansi.a(' ') } ansi.a('│') val dieFrameWidth = 3 + 6 * if (pair.die.size >= 10) 3 else 2 for (row in 1..8) { ansi.cursor(offsetY + row, 1) ansi.a("│ ") ansi.cursor(offsetY + row, dieFrameWidth + 4) (dieFrameWidth + 4 until CONSOLE_WIDTH).forEach { ansi.a(' ') } ansi.a('│') } drawDieSizeBig(4, offsetY + 1, pair) } private fun drawHand(offsetY: Int, hand: Hand, checkedDice: HandMask, activePositions: HandMask) { val handString = loadString("hand").toUpperCase() val alliesString = loadString("allies").toUpperCase() val capacity = hand.capacity val size = hand.dieCount val slots = max(size, capacity) val alliesSize = hand.allyDieCount var currentY = offsetY var currentX = 1 //Hand title ansi.cursor(currentY, currentX) ansi.a("│ ").a(handString) //Left border currentY += 1 currentX = 1 ansi.cursor(currentY, currentX) ansi.a("│ ╔") ansi.cursor(currentY + 1, currentX) ansi.a("│ ║") ansi.cursor(currentY + 2, currentX) ansi.a("│ ╚") ansi.cursor(currentY + 3, currentX) ansi.a("│ ") currentX += 3 //Main hand for (i in 0 until min(slots, MAX_HAND_SIZE)) { val die = hand.dieAt(i) val longDieName = die != null && die.size >= 10 //Top border ansi.cursor(currentY, currentX) if (i < capacity) { ansi.a("════").a(if (longDieName) "═" else "") } else { ansi.a("────").a(if (longDieName) "─" else "") } ansi.a(if (i < capacity - 1) '╤' else if (i == capacity - 1) '╗' else if (i < size - 1) '┬' else '┐') //Center row ansi.cursor(currentY + 1, currentX) ansi.a(' ') if (die != null) { drawDieSize(die, checkedDice.checkPosition(i)) } else { ansi.a(" ") } ansi.a(' ') ansi.a(if (i < capacity - 1) '│' else if (i == capacity - 1) '║' else '│') //Bottom border ansi.cursor(currentY + 2, currentX) if (i < capacity) { ansi.a("════").a(if (longDieName) '═' else "") } else { ansi.a("────").a(if (longDieName) '─' else "") } ansi.a(if (i < capacity - 1) '╧' else if (i == capacity - 1) '╝' else if (i < size - 1) '┴' else '┘') //Die number ansi.cursor(currentY + 3, currentX) if (activePositions.checkPosition(i)) { ansi.color(LIGHT_YELLOW) } ansi.a(String.format(" (%s) %s", shortcut(i), if (longDieName) " " else "")) ansi.reset() currentX += 5 + if (longDieName) 1 else 0 } //Ally subhand if (alliesSize > 0) { currentY = offsetY //Ally title ansi.cursor(currentY, handString.length + 5) (handString.length + 5 until currentX).forEach { ansi.a(' ') } ansi.a(" ").a(alliesString) (currentX + alliesString.length + 5 until CONSOLE_WIDTH).forEach { ansi.a(' ') } ansi.a('│') //Left border currentY += 1 ansi.cursor(currentY, currentX) ansi.a(" ┌") ansi.cursor(currentY + 1, currentX) ansi.a(" │") ansi.cursor(currentY + 2, currentX) ansi.a(" └") ansi.cursor(currentY + 3, currentX) ansi.a(" ") currentX += 4 //Ally slots for (i in 0 until min(alliesSize, MAX_HAND_ALLY_SIZE)) { val allyDie = hand.allyDieAt(i)!! val longDieName = allyDie.size >= 10 //Top border ansi.cursor(currentY, currentX) ansi.a("────").a(if (longDieName) "─" else "") ansi.a(if (i < alliesSize - 1) '┬' else '┐') //Center row ansi.cursor(currentY + 1, currentX) ansi.a(' ') drawDieSize(allyDie, checkedDice.checkAllyPosition(i)) ansi.a(" │") //Bottom border ansi.cursor(currentY + 2, currentX) ansi.a("────").a(if (longDieName) "─" else "") ansi.a(if (i < alliesSize - 1) '┴' else '┘') //Die number ansi.cursor(currentY + 3, currentX) if (activePositions.checkAllyPosition(i)) { ansi.color(LIGHT_YELLOW) } ansi.a(String.format(" (%s) %s", shortcut(i + 10), if (longDieName) " " else "")).reset() currentX += 5 + if (longDieName) 1 else 0 } } else { ansi.cursor(offsetY, 9) (9 until CONSOLE_WIDTH).forEach { ansi.a(' ') } ansi.a('│') ansi.cursor(offsetY + 4, currentX) (currentX until CONSOLE_WIDTH).forEach { ansi.a(' ') } ansi.a('│') } //Clear the end of the line (0..3).forEach { row -> ansi.cursor(currentY + row, currentX) (currentX until CONSOLE_WIDTH).forEach { ansi.a(' ') } ansi.a('│') } } override fun drawHeroTurnStart(hero: Hero) { val centerY = (CONSOLE_HEIGHT - 5) / 2 (1 until centerY).forEach { drawBlankLine(it, false) } ansi.color(heroColors[hero.type]) drawHorizontalLine(centerY, '─') drawHorizontalLine(centerY + 4, '─') ansi.reset() ansi.cursor(centerY + 1, 1).eraseLine() ansi.cursor(centerY + 3, 1).eraseLine() ansi.cursor(centerY + 2, 1) val text = String.format(loadString("heros_turn"), hero.name.toUpperCase()) val index = text.indexOf(hero.name.toUpperCase()) val center = (CONSOLE_WIDTH - text.length) / 2 ansi.cursor(centerY + 2, center) ansi.eraseLine(Ansi.Erase.BACKWARD) ansi.a(text.substring(0, index)) ansi.color(heroColors[hero.type]).a(hero.name.toUpperCase()).reset() ansi.a(text.substring(index + hero.name.length)) ansi.eraseLine(Ansi.Erase.FORWARD) (centerY + 5..CONSOLE_HEIGHT).forEach { drawBlankLine(it, false) } render() } override fun drawLocationInteriorScreen( location: Location, heroesAtLocation: List<Hero>, timer: Int, currentHero: Hero, battleCheck: DieBattleCheck?, encounteredDie: DiePair?, pickedDice: HandMask, activePositions: HandMask, statusMessage: StatusMessage, actions: ActionList) { //Top panel drawLocationTopPanel(location, heroesAtLocation, currentHero, timer) //Encounter info when { battleCheck != null -> drawBattleCheck(4, battleCheck) encounteredDie != null -> drawExplorationResult(4, encounteredDie) else -> (4..12).forEach { drawBlankLine(it) } } //Fill blank space val bottomHalfTop = CONSOLE_HEIGHT - 11 (13 until bottomHalfTop).forEach { drawBlankLine(it) } //Hero-specific info drawLocationHeroPanel(bottomHalfTop, currentHero) drawHand(bottomHalfTop + 3, currentHero.hand, pickedDice, activePositions) //Separator ansi.cursor(bottomHalfTop + 8, 1) ansi.a('├') (2 until CONSOLE_WIDTH).forEach { ansi.a('─') } ansi.a('┤') //Status and actions drawStatusMessage(bottomHalfTop + 9, statusMessage) drawActionList(bottomHalfTop + 10, actions) //Bottom border ansi.cursor(CONSOLE_HEIGHT, 1) ansi.a('└') (2 until CONSOLE_WIDTH).forEach { ansi.a('─') } ansi.a('┘') //Finalize render() } override fun drawGameLoss(message: StatusMessage) { val centerY = CONSOLE_HEIGHT / 2 (1 until centerY).forEach { drawBlankLine(it, false) } val data = loadString(message.toString().toLowerCase()).toUpperCase() drawCenteredCaption(centerY, data, LIGHT_RED, false) (centerY + 1..CONSOLE_HEIGHT).forEach { drawBlankLine(it, false) } render() } } 

, . IDE ANSI, ( ). , ANSI Windows — , 10- cmd.exe ( , , ). PowerShell ( ). , — ( , ). .

.


— . . , , , . .

, GameInteractor . , . -, . . -, . , Reader , Scanner , Console : ENTER . - KeyListenerلكن ، ولكن مرتبط بإطار عمل Swing ، وتطبيق وحدة التحكم لدينا هو دون كل هذا بهرج الرسم.

ماذا تفعل؟ , , . «, »… ? , , , . jLine , ( ). , , , Windows, Linux/UNIX ( ). , . , , .

 <dependency> <groupId>jline</groupId> <artifactId>jline</artifactId> <version>2.14.6</version> <scope>compile</scope> </dependency> 

, , , , ConsoleReader readCharacter() . , ( , ). — : ( Action.Type ) .

« , ? escape- , , . ?»

, , « »: , F-, Home, Insert, PgUp/Dn, End, Delete, num-pad . , . ConsoleInteractor .

 abstract class ConsoleInteractor { private val reader = ConsoleReader() private val mapper = mapOf( CONFIRM to 13.toChar(), CANCEL to 27.toChar(), EXPLORE_LOCATION to 'e', FINISH_TURN to 'f', ACQUIRE to 'a', LEAVE to 'l', FORFEIT to 'f', HIDE to 'h', DISCARD to 'd', ) protected fun read() = reader.readCharacter().toChar() protected open fun getIndexForKey(key: Char) = "1234567890abcdefghijklmnopqrstuvw".indexOf(key) } 

mapper read() . getIndexForKey() , , . GameInteractor .



, , :

 class ConsoleGameInteractor : ConsoleInteractor(), GameInteractor { override fun anyInput() { read() } override fun pickAction(list: ActionList): Action { while (true) { val key = read() list .filter(Action::isEnabled) .find { mapper[it.type] == key } ?.let { return it } } } override fun pickDiceFromHand(activePositions: HandMask, actions: ActionList) : Action { while (true) { val key = read() actions.forEach { if (mapper[it.type] == key && it.isEnabled) return it } when (key) { in '1'..'9' -> { val index = key - '1' if (activePositions.checkPosition(index)) { return Action(HAND_POSITION, data = index) } } '0' -> { if (activePositions.checkPosition(9)) { return Action(HAND_POSITION, data = 9) } } in 'a'..'f' -> { val allyIndex = key - 'a' if (activePositions.checkAllyPosition(allyIndex)) { return Action(HAND_ALLY_POSITION, data = allyIndex) } } } } } } 

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

الخطوة الحادية عشرة. الأصوات والموسيقى


-? (, , ), , . . , , (, , , ). , ( ), , ( -) , . , , .

, — , . , . , . , . , , — , — , . , : , , — , . , — , — . ? , . , ( , , ).

مع النظرية ، على ما يبدو ، حلها ، والآن حان الوقت للمضي قدماً في الممارسة. وقبل ذلك تحتاج إلى طرح سؤال: أين ، في الواقع ، تأخذ ملفات اللعبة؟ الطريقة الأسهل والأكثر أمانًا - لتسجيلهم بنفسك بجودة قبيحة ، باستخدام ميكروفون قديم أو حتى استخدام الهاتف. الإنترنت مليء بمقاطع الفيديو حول كيف يمكن لفك قمم الأناناس أو كسر الجليد باستخدام صندوق الحذاء تحقيق تأثير تكسير العظام والعمود الفقري المقرمش. إذا لم تكن غريباً على جماليات السريالية ، فيمكنك استخدام أدوات الصوت أو المطبخ الخاصة بك كأداة موسيقية (هناك أمثلة - وحتى أمثلة ناجحة - حيث تم ذلك). أو يمكنك الذهاب إلى freesound.org , - . : — , ( ) .

, , - classpath. , .

 enum class Sound { TURN_START, //Hero starts the turn BATTLE_CHECK_ROLL, //Perform check, type BATTLE_CHECK_SUCCESS, //Check was successful BATTLE_CHECK_FAILURE, //Check failed DIE_DRAW, //Draw die from bag DIE_HIDE, //Remove die to bag DIE_DISCARD, //Remove die to pile DIE_REMOVE, //Remove die entirely DIE_PICK, //Check/uncheck the die TRAVEL, //Move hero to another location ENCOUNTER_STAT, //Hero encounters STAT die ENCOUNTER_DIVINE, //Hero encounters DIVINE die ENCOUNTER_ALLY, //Hero encounters ALLY die ENCOUNTER_WOUND, //Hero encounters WOUND die ENCOUNTER_OBSTACLE, //Hero encounters OBSTACLE die ENCOUNTER_ENEMY, //Hero encounters ENEMY die ENCOUNTER_VILLAIN, //Hero encounters VILLAIN die DEFEAT_OBSTACLE, //Hero defeats OBSTACLE die DEFEAT_ENEMY, //Hero defeats ENEMY die DEFEAT_VILLAIN, //Hero defeats VILLAIN die TAKE_DAMAGE, //Hero takes damage HERO_DEATH, //Hero death CLOSE_LOCATION, //Location closed GAME_VICTORY, //Scenario completed GAME_LOSS, //Scenario failed ERROR, //When something unexpected happens } 

, . , :

 interface SoundPlayer { fun play(sound: Sound) } 

GameRenderer GameInteractor , Game . , :

 class MuteSoundPlayer : SoundPlayer { override fun play(sound: Sound) { //Do nothing } } 

, .
, , . , , ( , ) ( , - , ). , ( ), , , - . , - freemusicarchive.org soundcloud.com ( YouTube) - . ambient — , . : , , .

:

 enum class Music { SCENARIO_MUSIC_1, SCENARIO_MUSIC_2, SCENARIO_MUSIC_3, } 

.

 interface MusicPlayer { fun play(music: Music) fun stop() } class MuteMusicPlayer : MusicPlayer { override fun play(music: Music) { //Do nothing } override fun stop() { //Do nothing } } 

, : , . , (/, ), .

- . , (singleton). . :



Audio — singleton. … , (facade) — , ( ) . , , - . :

 object Audio { private var soundPlayer: SoundPlayer = MuteSoundPlayer() private var musicPlayer: MusicPlayer = MuteMusicPlayer() fun init(soundPlayer: SoundPlayer, musicPlayer: MusicPlayer) { this.soundPlayer = soundPlayer this.musicPlayer = musicPlayer } fun playSound(sound: Sound) = this.soundPlayer.play(sound) fun playMusic(music: Music) = this.musicPlayer.play(music) fun stopMusic() = this.musicPlayer.stop() } 

init() - - ( ) , . , , — .

هذا كل شيء. . (, , ), Java AudioSystem Clip . , , - ( classpath, ?):

 import javax.sound.sampled.AudioSystem class BasicSoundPlayer : SoundPlayer { private fun pathToFile(sound: Sound) = "/sound/${sound.toString().toLowerCase()}.wav" override fun play(sound: Sound) { val url = javaClass.getResource(pathToFile(sound)) val audioIn = AudioSystem.getAudioInputStream(url) val clip = AudioSystem.getClip() clip.open(audioIn) clip.start() } } 

open() IOException ( - — - ), try-catch , , .

« , ...»

. , (, mp3) Java , ( ). , JLayer . :

 <dependencies> <dependency> <groupId>com.googlecode.soundlibs</groupId> <artifactId>jlayer</artifactId> <version>1.0.1.4</version> <scope>compile</scope> </dependency> </dependencies> 

.

 class BasicMusicPlayer : MusicPlayer { private var currentMusic: Music? = null private var thread: PlayerThread? = null private fun pathToFile(music: Music) = "/music/${music.toString().toLowerCase()}.mp3" override fun play(music: Music) { if (currentMusic == music) { return } currentMusic = music thread?.finish() Thread.yield() thread = PlayerThread(pathToFile(music)) thread?.start() } override fun stop() { currentMusic = null thread?.finish() } // Thread responsible for playback private inner class PlayerThread(private val musicPath: String) : Thread() { private lateinit var player: Player private var isLoaded = false private var isFinished = false init { isDaemon = true } override fun run() { loop@ while (!isFinished) { try { player = Player(javaClass.getResource(musicPath).openConnection().apply { useCaches = false }.getInputStream()) isLoaded = true player.play() } catch (ex: Exception) { finish() break@loop } player.close() } } fun finish() { isFinished = true this.interrupt() if (isLoaded) { player.close() } } } } 

أولاً ، تنفذ هذه المكتبة التشغيل بشكل متزامن ، حيث تحظر البث الرئيسي حتى يتم الوصول إلى نهاية الملف. لذلك ، يجب أن ننفذ خيطًا منفصلاً ( PlayerThread) ونجعله "اختياريًا" (خفي) ، بحيث لا يتدخل بأي حال من الأحوال في التطبيق لإنهاء مبكرًا. ثانياً ، currentMusicيتم تخزين معرف ملف الموسيقى قيد التشغيل حاليًا ( ) في رمز المشغل . إذا جاء أمر ثانٍ فجأة لتشغيله ، فلن نبدأ التشغيل من البداية. ثالثًا ، عند الوصول إلى نهاية ملف الموسيقى ، سيبدأ تشغيله مرة أخرى - وهكذا حتى يتم إيقاف البث صراحةً بواسطة الأمرfinish() ( , ). -, , — , , , , - , . .

.


, . لماذا؟

"لا يوجد روسي! .. لا يوجد روسي! .. أضف اللغة الروسية! .. طورها كلاب!"

افتح صفحة أي لعبة قصة مثيرة للاهتمام (خاصة المحمول) على موقع المتجر وقراءة المراجعات. هل سيبدأون في مدح الرسومات المرسومة باليد؟ أو نتعجب من الصوت في الغلاف الجوي؟ أو ناقش قصة مثيرة تدمن من اللحظة الأولى ولا تتركها حتى النهاية؟

لا."اللاعبين" غير الراضين سوف يرشدون مجموعة من الوحدات ويحذفون اللعبة عمومًا. وبعد ذلك سوف يحتاجون أيضًا إلى استعادة الأموال - وكل هذا لسبب واحد بسيط. نعم ، لقد نسيت أن تترجم تحفة إلى جميع لغات العالم 95. أو بالأحرى الشخص الذي تصرخ ناقلاته بصوت عالٍ. وهذا كل شيء! هل تفهمين , , — . .

. , , … , , ( ). , .

. , String ? . , , . , :

 class TestEnemyTemplate : EnemyTemplate { override val name = "Test enemy" override val description = "Some enemy standing in your way." override val nameLocalizations = mapOf( "ru" to " -", "ar" to "بعض العدو", "iw" to "איזה אויב", "zh" to "一些敵人", "ua" to "і " ) override val descriptionLocalizations = mapOf( "ru" to " - .", "ar" to "وصف العدو", "iw" to "תיאור האויב", "zh" to "一些敵人的描述", "ua" to " ї і   ." ) override val traits = listOf<Trait>() } 

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

 class LocalizedString(defaultValue: String, localizations: Map<String, String>) { private val default: String = defaultValue private val values: Map<String, String> = localizations.toMap() operator fun get(lang: String) = values.getOrDefault(lang, default) override fun equals(other: Any?) = when { this === other -> true other !is LocalizedString -> false else -> default == other.default } override fun hashCode(): Int { return default.hashCode() } } 

وتصحيح رمز المولد وفقا لذلك.

 fun generateEnemy(template: EnemyTemplate) = Enemy().apply { name = LocalizedString(template.name, template.nameLocalizations) description = LocalizedString(template.description, template.descriptionLocalizations) template.traits.forEach { addTrait(it) } } 

وبطبيعة الحال ، يجب تطبيق نفس النهج على الأنواع المتبقية من القوالب. عندما تكون التغييرات جاهزة ، يمكن استخدامها دون صعوبة.

 val language = Locale.getDefault().language val enemyName = enemy.name[language] 

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

تعاملنا مع القوالب ، يبقى لتوطين خطوط الخدمة المستخدمة في تطبيقنا. لحسن الحظ ، فإنه ResourceBundleيحتوي بالفعل على جميع الآليات اللازمة. من الضروري فقط إعداد الملفات مع الترجمات وتغيير طريقة تنزيلها.

 # Game status messages choose_dice_perform_check=    : end_of_turn_discard_extra= :   : end_of_turn_discard_optional= :    : choose_action_before_exploration=,  : choose_action_after_exploration= .   ? encounter_physical=  .   . encounter_somatic=  .   . encounter_mental=  .   . encounter_verbal=  .   . encounter_divine=  .    : die_acquire_success=   ! die_acquire_failure=    . game_loss_out_of_time=    # Die types physical= somatic= mental= verbal= divine= ally= wound= enemy= villain= obstacle= # Hero types and descriptions brawler= hunter= # Various labels avg= bag= bag_size=  class= closed= discard= empty= encountered=  fail= hand= heros_turn= %s max= min= perform_check= : pile= received_new_die=   result= success= sum= time= total= # Action names and descriptions action_confirm_key=ENTER action_confirm_name= action_cancel_key=ESC action_cancel_name= action_explore_location_key=E action_explore_location_name= action_finish_turn_key=F action_finish_turn_name=  action_hide_key=H action_bag_name= action_discard_key=D action_discard_name= action_acquire_key=A action_acquire_name= action_leave_key=L action_leave_name= action_forfeit_key=F action_forfeit_name= 

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

 class PropertiesStringLoader(locale: Locale) : StringLoader { private val properties = ResourceBundle.getBundle("text.strings", locale) override fun loadString(key: String) = properties.getString(key) ?: "" } 
.
, ResourceBundle , . — ( string.properties ). …

! !
, Unicode .properties Java 9. ISO-8859-1 — ResourceBundle . , , — . Unicode- — , , : '\uXXXX' . , , Java native2ascii , . :

 # Game status messages choose_dice_perform_check=\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u043a\u0443\u0431\u0438\u043a\u0438 \u0434\u043b\u044f \u043f\u0440\u043e\u0445\u043e\u0436\u0434\u0435\u043d\u0438\u044f \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0438: end_of_turn_discard_extra=\u041a\u041e\u041d\u0415\u0426 \u0425\u041e\u0414\u0410: \u0421\u0431\u0440\u043e\u0441\u044c\u0442\u0435 \u043b\u0438\u0448\u043d\u0438\u0435 \u043a\u0443\u0431\u0438\u043a\u0438: end_of_turn_discard_optional=\u041a\u041e\u041d\u0415\u0426 \u0425\u041e\u0414\u0410: \u0421\u0431\u0440\u043e\u0441\u044c\u0442\u0435 \u043a\u0443\u0431\u0438\u043a\u0438 \u043f\u043e \u0436\u0435\u043b\u0430\u043d\u0438\u044e: choose_action_before_exploration=\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435, \u0447\u0442\u043e \u0434\u0435\u043b\u0430\u0442\u044c: choose_action_after_exploration=\u0418\u0441\u0441\u043b\u0435\u0434\u043e\u0432\u0430\u043d\u0438\u0435 \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u043e. \u0427\u0442\u043e \u0434\u0435\u043b\u0430\u0442\u044c \u0434\u0430\u043b\u044c\u0448\u0435? encounter_physical=\u0412\u0441\u0442\u0440\u0435\u0447\u0435\u043d \u0424\u0418\u0417\u0418\u0427\u0415\u0421\u041a\u0418\u0419 \u043a\u0443\u0431\u0438\u043a. \u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u043f\u0440\u043e\u0439\u0442\u0438 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0443. encounter_somatic=\u0412\u0441\u0442\u0440\u0435\u0447\u0435\u043d \u0421\u041e\u041c\u0410\u0422\u0418\u0427\u0415\u0421\u041a\u0418\u0419 \u043a\u0443\u0431\u0438\u043a. \u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u043f\u0440\u043e\u0439\u0442\u0438 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0443. encounter_mental=\u0412\u0441\u0442\u0440\u0435\u0447\u0435\u043d \u041c\u0415\u041d\u0422\u0410\u041b\u042c\u041d\u042b\u0419 \u043a\u0443\u0431\u0438\u043a. \u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u043f\u0440\u043e\u0439\u0442\u0438 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0443. encounter_verbal=\u0412\u0441\u0442\u0440\u0435\u0447\u0435\u043d \u0412\u0415\u0420\u0411\u0410\u041b\u042c\u041d\u042b\u0419 \u043a\u0443\u0431\u0438\u043a. \u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u043f\u0440\u043e\u0439\u0442\u0438 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0443. encounter_divine=\u0412\u0441\u0442\u0440\u0435\u0447\u0435\u043d \u0411\u041e\u0416\u0415\u0421\u0422\u0412\u0415\u041d\u041d\u042b\u0419 \u043a\u0443\u0431\u0438\u043a. \u041c\u043e\u0436\u043d\u043e \u0432\u0437\u044f\u0442\u044c \u0431\u0435\u0437 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0438: die_acquire_success=\u0412\u044b \u043f\u043e\u043b\u0443\u0447\u0438\u043b\u0438 \u043d\u043e\u0432\u044b\u0439 \u043a\u0443\u0431\u0438\u043a! die_acquire_failure=\u0412\u0430\u043c \u043d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u043a\u0443\u0431\u0438\u043a. game_loss_out_of_time=\u0423 \u0432\u0430\u0441 \u0437\u0430\u043a\u043e\u043d\u0447\u0438\u043b\u043e\u0441\u044c \u0432\u0440\u0435\u043c\u044f 

. — . — . , IDE ( ) « », — - ( ), IDE, .

, . getBundle() , , , ResourceBundle.Control — - .

 class PropertiesStringLoader(locale: Locale) : StringLoader { private val properties = ResourceBundle.getBundle( "text.strings", locale, Utf8ResourceBundleControl()) override fun loadString(key: String) = properties.getString(key) ?: "" } 

, , :

 class Utf8ResourceBundleControl : ResourceBundle.Control() { @Throws(IllegalAccessException::class, InstantiationException::class, IOException::class) override fun newBundle(baseName: String, locale: Locale, format: String, loader: ClassLoader, reload: Boolean): ResourceBundle? { val bundleName = toBundleName(baseName, locale) return when (format) { "java.class" -> super.newBundle(baseName, locale, format, loader, reload) "java.properties" -> with((if ("://" in bundleName) null else toResourceName(bundleName, "properties")) ?: return null) { when { reload -> reload(this, loader) else -> loader.getResourceAsStream(this) }?.let { stream -> InputStreamReader(stream, "UTF-8").use { r -> PropertyResourceBundle(r) } } } else -> throw IllegalArgumentException("Unknown format: $format") } } @Throws(IOException::class) private fun reload(resourceName: String, classLoader: ClassLoader): InputStream { classLoader.getResource(resourceName)?.let { url -> url.openConnection().let { connection -> connection.useCaches = false return connection.getInputStream() } } throw IOException("Unable to load data!") } } 

, … , ( ) — ( Kotlin ). — , .properties UTF-8 - .

— JRE:

 java -Duser.language=ru -jar path_to_project\Dice\target\dice-1.0-jar-with-dependencies.jar 

Windows,
, Windows (cmd.exe) 437 ( DOSLatinUS), — . , UTF-8 , :

 chcp 65001 

Java , , . :

 java -Dfile.encoding=UTF-8 -Duser.language=ru -jar path_to_project\Dice\target\dice-1.0-jar-with-dependencies.jar 

, , Unicode- (, Lucida Console)

, : « !»

-


.

.


, , , . -, , . -, , . -, ( , ), , , — ( , ?).

. , main() , . سنحتاج:

  • النصي والتضاريس ؛
  • الأبطال.
  • تنفيذ واجهة GameInteractor.
  • تنفيذ واجهات GameRendererو StringLoader؛
  • تنفيذ واجهات SoundPlayerو MusicPlayer؛
  • كائن الفئة Game.
  • زجاجة من الشمبانيا.

دعنا نذهب!

 fun main(args: Array<String>) { Audio.init(BasicSoundPlayer(), BasicMusicPlayer()) val loader = PropertiesStringLoader(Locale.getDefault()) val renderer = ConsoleGameRenderer(loader) val interactor = ConsoleGameInteractor() val template = TestScenarioTemplate() val scenario = generateScenario(template, 1) val locations = generateLocations(template, 1, heroes.size) val heroes = listOf( generateHero(Hero.Type.BRAWLER, "Brawler"), generateHero(Hero.Type.HUNTER, "Hunter") ) val game = Game(renderer, interactor, scenario, locations, heroes) game.start() } 

نطلق ونستمتع بأول نموذج عمل. ها أنت ذا.

الخطوة الرابعة عشرة. لعبة التوازن


أم ...

الخطوة الخامسة عشرة. اختبارات


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

"كيف؟ الآن فقط؟ نعم ، يجب كتابة الاختبارات في البداية ، ثم الرمز! "

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

: ( ) . , . , ( ), , . , Renderer — . , .

, . . : , . , . , :

 public class DieGeneratorTest { @Test public void testGetMaxLevel() { assertEquals("Max level should be 3", 3, DieGeneratorKt.getMaxLevel()); } @Test public void testDieGenerationSize() { DieTypeFilter filter = new SingleDieTypeFilter(Die.Type.ALLY); List<? extends List<Integer>> allowedSizes = Arrays.asList( null, Arrays.asList(4, 6, 8), Arrays.asList(4, 6, 8, 10), Arrays.asList(6, 8, 10, 12) ); IntStream.rangeClosed(1, 3).forEach(level -> { for (int i = 0; i < 10; i++) { int size = DieGeneratorKt.generateDie(filter, level).getSize(); assertTrue("Incorrect level of die generated: " + size, allowedSizes.get(level).contains(size)); assertTrue("Incorrect die size: " + size, size >= 4); assertTrue("Incorrect die size: " + size, size <= 12); assertTrue("Incorrect die size: " + size, size % 2 == 0); } }); } @Test public void testDieGenerationType() { List<Die.Type> allowedTypes1 = Arrays.asList(Die.Type.PHYSICAL); List<Die.Type> allowedTypes2 = Arrays.asList(Die.Type.PHYSICAL, Die.Type.SOMATIC, Die.Type.MENTAL, Die.Type.VERBAL); List<Die.Type> allowedTypes3 = Arrays.asList(Die.Type.ALLY, Die.Type.VILLAIN, Die.Type.ENEMY); for (int i = 0; i < 10; i++) { Die.Type type1 = DieGeneratorKt.generateDie(new SingleDieTypeFilter(Die.Type.PHYSICAL), 1).getType(); assertTrue("Incorrect die type: " + type1, allowedTypes1.contains(type1)); Die.Type type2 = DieGeneratorKt.generateDie(new StatsDieTypeFilter(), 1).getType(); assertTrue("Incorrect die type: " + type2, allowedTypes2.contains(type2)); Die.Type type3 = DieGeneratorKt.generateDie(new MultipleDieTypeFilter(Die.Type.ALLY, Die.Type.VILLAIN, Die.Type.ENEMY), 1).getType(); assertTrue("Incorrect die type: " + type3, allowedTypes3.contains(type3)); } } } 

أو هكذا:

 public class BagGeneratorTest { @Test public void testGenerateBag() { BagTemplate template1 = new BagTemplate(); template1.addPlan(0, 10, new SingleDieTypeFilter(Die.Type.PHYSICAL)); template1.addPlan(5, 5, new SingleDieTypeFilter(Die.Type.SOMATIC)); template1.setFixedDieCount(null); BagTemplate template2 = new BagTemplate(); template2.addPlan(10, 10, new SingleDieTypeFilter(Die.Type.DIVINE)); template2.setFixedDieCount(5); BagTemplate template3 = new BagTemplate(); template3.addPlan(10, 10, new SingleDieTypeFilter(Die.Type.ALLY)); template3.setFixedDieCount(50); for (int i = 0; i < 10; i++) { Bag bag1 = BagGeneratorKt.generateBag(template1, 1); assertTrue("Incorrect bag size: " + bag1.getSize(), bag1.getSize() >= 5 && bag1.getSize() <= 15); assertEquals("Incorrect number of SOMATIC dice", 5, bag1.examine().stream().filter(d -> d.getType() == Die.Type.SOMATIC).count()); Bag bag2 = BagGeneratorKt.generateBag(template2, 1); assertEquals("Incorrect bag size", 5, bag2.getSize()); Bag bag3 = BagGeneratorKt.generateBag(template3, 1); assertEquals("Incorrect bag size", 50, bag3.getSize()); List<Die.Type> dieTypes3 = bag3.examine().stream().map(Die::getType).distinct().collect(Collectors.toList()); assertEquals("Incorrect die types", 1, dieTypes3.size()); assertEquals("Incorrect die types", Die.Type.ALLY, dieTypes3.get(0)); } } } 

:

 public class LocationGeneratorTest { private void testLocationGeneration(String name, LocationTemplate template) { System.out.println("Template: " + template.getName()); assertEquals("Incorrect template type", name, template.getName()); IntStream.rangeClosed(1, 3).forEach(level -> { Location location = LocationGeneratorKt.generateLocation(template, level); assertEquals("Incorrect location type", name, location.getName().get("")); assertTrue("Location not open by default", location.isOpen()); int closingDifficulty = location.getClosingDifficulty(); assertTrue("Closing difficulty too small", closingDifficulty > 0); assertEquals("Incorrect closing difficulty", closingDifficulty, template.getBasicClosingDifficulty() + level * 2); Bag bag = location.getBag(); assertNotNull("Bag is null", bag); assertTrue("Bag is empty", location.getBag().getSize() > 0); Deck<Enemy> enemies = location.getEnemies(); assertNotNull("Enemies are null", enemies); assertEquals("Incorrect enemy threat count", enemies.getSize(), template.getEnemyCardsCount()); if (bag.drawOfType(Die.Type.ENEMY) != null) { assertTrue("Enemy cards not specified", enemies.getSize() > 0); } Deck<Obstacle> obstacles = location.getObstacles(); assertNotNull("Obstacles are null", obstacles); assertEquals("Incorrect obstacle threat count", obstacles.getSize(), template.getObstacleCardsCount()); List<SpecialRule> specialRules = location.getSpecialRules(); assertNotNull("SpecialRules are null", specialRules); }); } @Test public void testGenerateLocation() { testLocationGeneration("Test Location", new TestLocationTemplate()); testLocationGeneration("Test Location 2", new TestLocationTemplate2()); } } 

«, , ! ? Java???»

. , , , . , , ( , , ). - - , : ( ?).

. , HandMaskRule ? , - , (, « , , — , , — » — ?). ? … . , , ( ), , , , . ? ? ? - ? . — , . … , :

 public class TripleDieHandMaskRuleTest { private Hand hand; @Before public void init() { hand = new Hand(10); hand.addDie(new Die(Die.Type.PHYSICAL, 4)); //0 hand.addDie(new Die(Die.Type.PHYSICAL, 4)); //1 hand.addDie(new Die(Die.Type.SOMATIC, 4)); //2 hand.addDie(new Die(Die.Type.SOMATIC, 4)); //3 hand.addDie(new Die(Die.Type.MENTAL, 4)); //4 hand.addDie(new Die(Die.Type.MENTAL, 4)); //5 hand.addDie(new Die(Die.Type.VERBAL, 4)); //6 hand.addDie(new Die(Die.Type.VERBAL, 4)); //7 hand.addDie(new Die(Die.Type.DIVINE, 4)); //8 hand.addDie(new Die(Die.Type.DIVINE, 4)); //9 hand.addDie(new Die(Die.Type.ALLY, 4)); //A (0) hand.addDie(new Die(Die.Type.ALLY, 4)); //B (1) } @Test public void testRule1() { HandMaskRule rule = new TripleDieHandMaskRule( hand, new Die.Type[]{Die.Type.PHYSICAL, Die.Type.SOMATIC}, new Die.Type[]{Die.Type.MENTAL, Die.Type.VERBAL}, new Die.Type[]{Die.Type.PHYSICAL, Die.Type.ALLY} ); HandMask mask = new HandMask(); assertTrue("Ally should be on", rule.isAllyPositionActive(mask, 0)); assertTrue("Ally should be on", rule.isAllyPositionActive(mask, 1)); assertTrue("Should be on", rule.isPositionActive(mask, 0)); assertTrue("Should be on", rule.isPositionActive(mask, 1)); assertTrue("Should be on", rule.isPositionActive(mask, 2)); assertTrue("Should be on", rule.isPositionActive(mask, 3)); assertTrue("Should be on", rule.isPositionActive(mask, 4)); assertTrue("Should be on", rule.isPositionActive(mask, 5)); assertTrue("Should be on", rule.isPositionActive(mask, 6)); assertTrue("Should be on", rule.isPositionActive(mask, 7)); assertFalse("Should be off", rule.isPositionActive(mask, 8)); assertFalse("Should be off", rule.isPositionActive(mask, 9)); assertFalse("Rule should not be met yet", rule.checkMask(mask)); mask.addPosition(0); assertTrue("Ally should be on", rule.isAllyPositionActive(mask, 0)); assertTrue("Ally should be on", rule.isAllyPositionActive(mask, 1)); assertTrue("Should be on", rule.isPositionActive(mask, 0)); assertTrue("Should be on", rule.isPositionActive(mask, 1)); assertTrue("Should be on", rule.isPositionActive(mask, 2)); assertTrue("Should be on", rule.isPositionActive(mask, 3)); assertTrue("Should be on", rule.isPositionActive(mask, 4)); assertTrue("Should be on", rule.isPositionActive(mask, 5)); assertTrue("Should be on", rule.isPositionActive(mask, 6)); assertTrue("Should be on", rule.isPositionActive(mask, 7)); assertFalse("Should be off", rule.isPositionActive(mask, 8)); assertFalse("Should be off", rule.isPositionActive(mask, 9)); assertFalse("Rule should not be met yet", rule.checkMask(mask)); mask.addPosition(4); assertTrue("Ally should be on", rule.isAllyPositionActive(mask, 0)); assertTrue("Ally should be on", rule.isAllyPositionActive(mask, 1)); assertTrue("Should be on", rule.isPositionActive(mask, 0)); assertTrue("Should be on", rule.isPositionActive(mask, 1)); assertTrue("Should be on", rule.isPositionActive(mask, 2)); assertTrue("Should be on", rule.isPositionActive(mask, 3)); assertTrue("Should be on", rule.isPositionActive(mask, 4)); assertFalse("Should be off", rule.isPositionActive(mask, 5)); assertFalse("Should be off", rule.isPositionActive(mask, 6)); assertFalse("Should be off", rule.isPositionActive(mask, 7)); assertFalse("Should be off", rule.isPositionActive(mask, 8)); assertFalse("Should be off", rule.isPositionActive(mask, 9)); assertFalse("Rule should not be met yet", rule.checkMask(mask)); mask.addAllyPosition(0); assertTrue("Ally should be on", rule.isAllyPositionActive(mask, 0)); assertFalse("Ally should be off", rule.isAllyPositionActive(mask, 1)); assertTrue("Should be on", rule.isPositionActive(mask, 0)); assertFalse("Should be off", rule.isPositionActive(mask, 1)); assertFalse("Should be off", rule.isPositionActive(mask, 2)); assertFalse("Should be off", rule.isPositionActive(mask, 3)); assertTrue("Should be on", rule.isPositionActive(mask, 4)); assertFalse("Should be off", rule.isPositionActive(mask, 5)); assertFalse("Should be off", rule.isPositionActive(mask, 6)); assertFalse("Should be off", rule.isPositionActive(mask, 7)); assertFalse("Should be off", rule.isPositionActive(mask, 8)); assertFalse("Should be off", rule.isPositionActive(mask, 9)); assertTrue("Rule should be met", rule.checkMask(mask)); mask.removePosition(0); assertTrue("Ally should be on", rule.isAllyPositionActive(mask, 0)); assertFalse("Ally should be off", rule.isAllyPositionActive(mask, 1)); assertTrue("Should be on", rule.isPositionActive(mask, 0)); assertTrue("Should be on", rule.isPositionActive(mask, 1)); assertTrue("Should be on", rule.isPositionActive(mask, 2)); assertTrue("Should be on", rule.isPositionActive(mask, 3)); assertTrue("Should be on", rule.isPositionActive(mask, 4)); assertFalse("Should be off", rule.isPositionActive(mask, 5)); assertFalse("Should be off", rule.isPositionActive(mask, 6)); assertFalse("Should be off", rule.isPositionActive(mask, 7)); assertFalse("Should be off", rule.isPositionActive(mask, 8)); assertFalse("Should be off", rule.isPositionActive(mask, 9)); assertFalse("Rule should not be met again", rule.checkMask(mask)); } } 

, , ( - ). ( , ), . , . -, , . , , - . . .

 class TripleDieHandMaskRule( hand: Hand, types1: Array<Die.Type>, types2: Array<Die.Type>, types3: Array<Die.Type>) : HandMaskRule(hand) { private val types1 = types1.toSet() private val types2 = types2.toSet() private val types3 = types3.toSet() override fun checkMask(mask: HandMask): Boolean { if (mask.positionCount + mask.allyPositionCount != 3) { return false } return getCheckedDice(mask).asSequence() .filter { it.type in types1 } .any { d1 -> getCheckedDice(mask) .filter { d2 -> d2 !== d1 } .filter { it.type in types2 } .any { d2 -> getCheckedDice(mask) .filter { d3 -> d3 !== d1 } .filter { d3 -> d3 !== d2 } .any { it.type in types3 } } } } override fun isPositionActive(mask: HandMask, position: Int): Boolean { if (mask.checkPosition(position)) { return true } val die = hand.dieAt(position) ?: return false return when (mask.positionCount + mask.allyPositionCount) { 0 -> die.type in types1 || die.type in types2 || die.type in types3 1 -> with(getCheckedDice(mask).first()) { (this.type in types1 && (die.type in types2 || die.type in types3)) || (this.type in types2 && (die.type in types1 || die.type in types3)) || (this.type in types3 && (die.type in types1 || die.type in types2)) } 2-> with(getCheckedDice(mask)) { val d1 = this[0] val d2 = this[1] (d1.type in types1 && d2.type in types2 && die.type in types3) || (d2.type in types1 && d1.type in types2 && die.type in types3) || (d1.type in types1 && d2.type in types3 && die.type in types2) || (d2.type in types1 && d1.type in types3 && die.type in types2) || (d1.type in types2 && d2.type in types3 && die.type in types1) || (d2.type in types2 && d1.type in types3 && die.type in types1) } 3 -> false else -> false } } override fun isAllyPositionActive(mask: HandMask, position: Int): Boolean { if (mask.checkAllyPosition(position)) { return true } if (hand.allyDieAt(position) == null) { return false } return when (mask.positionCount + mask.allyPositionCount) { 0 -> ALLY in types1 || ALLY in types2 || ALLY in types3 1 -> with(getCheckedDice(mask).first()) { (this.type in types1 && (ALLY in types2 || ALLY in types3)) || (this.type in types2 && (ALLY in types1 || ALLY in types3)) || (this.type in types3 && (ALLY in types1 || ALLY in types2)) } 2-> with(getCheckedDice(mask)) { val d1 = this[0] val d2 = this[1] (d1.type in types1 && d2.type in types2 && ALLY in types3) || (d2.type in types1 && d1.type in types2 && ALLY in types3) || (d1.type in types1 && d2.type in types3 && ALLY in types2) || (d2.type in types1 && d1.type in types3 && ALLY in types2) || (d1.type in types2 && d2.type in types3 && ALLY in types1) || (d2.type in types2 && d1.type in types3 && ALLY in types1) } 3 -> false else -> false } } } 

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

"وأنا <...> أيضًا <...> أنا سعيد <...> سعيد <...>. الحصول على! <...> مرة أخرى! <...> في الفجوة! "

الخطوة السادسة عشرة. نمطية


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

نحن نواجه مهمة تافهة إلى حد ما. من الضروري تقسيم جميع الفئات التي تم إنشاؤها حتى الآن إلى ثلاث مجموعات:

  • الوظائف الأساسية: الوحدة النمطية ، محرك اللعبة ، واجهات الموصل والتطبيقات المستقلة عن المنصة ( الأساسية ) ؛
  • قوالب السيناريوهات والتضاريس والأعداء والعقبات - مكونات ما يسمى بـ "المغامرة" ( المغامرة ) ؛
  • تطبيقات محددة للواجهات المحددة لمنصة معينة: في حالتنا ، تطبيق وحدة التحكم ( CLI ).

سوف تبدو نتيجة هذا الفصل في النهاية شيئًا مثل المخطط التالي:

مثل الممثلين في نهاية العرض ، يعيد أبطالنا اليوم دخول المشهد بكامل قوته


. .

Core
. — , . , . , . Maven ( ) .

pom.xml :

 <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>my.company</groupId> <artifactId>dice-core</artifactId> <version>1.0</version> <packaging>jar</packaging> <dependencies> <dependency> <groupId>org.jetbrains.kotlin</groupId> <artifactId>kotlin-stdlib</artifactId> <version>${kotlin.version}</version> </dependency> <dependency> <groupId>junit</groupId> <artifactId>junit-dep</artifactId> <version>4.8.2</version> <scope>test</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.jetbrains.kotlin</groupId> <!-- other Kotlin setup --> </plugin> </plugins> </build> <properties> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <maven.compiler.source>1.8</maven.compiler.source> <maven.compiler.target>1.8</maven.compiler.target> <kotlin.version>1.3.20</kotlin.version> <kotlin.compiler.incremental>true</kotlin.compiler.incremental> </properties> </project> 

:

 mvn -f "path_to_project/DiceCore/pom.xml" install 

Cli
— . . , , ( , — ). (, .).

pom.xml :

 <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>my.company</groupId> <artifactId>dice-cli</artifactId> <version>1.0</version> <packaging>jar</packaging> <dependencies> <dependency> <groupId>org.jetbrains.kotlin</groupId> <artifactId>kotlin-stdlib</artifactId> <version>${kotlin.version}</version> </dependency> <dependency> <groupId>my.company</groupId> <artifactId>dice-core</artifactId> <version>1.0</version> <scope>compile</scope> </dependency> <dependency> <groupId>org.fusesource.jansi</groupId> <artifactId>jansi</artifactId> <version>1.17.1</version> <scope>compile</scope> </dependency> <dependency> <groupId>jline</groupId> <artifactId>jline</artifactId> <version>2.14.6</version> <scope>compile</scope> </dependency> <dependency> <groupId>com.googlecode.soundlibs</groupId> <artifactId>jlayer</artifactId> <version>1.0.1.4</version> <scope>compile</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.jetbrains.kotlin</groupId> <!-- other Kotlin setup --> </plugin> <plugin> <artifactId>maven-assembly-plugin</artifactId> <version>2.6</version> <executions> <execution> <phase>package</phase> <goals> <goal>single</goal> </goals> </execution> </executions> <configuration> <descriptorRefs> <descriptorRef>jar-with-dependencies</descriptorRef> </descriptorRefs> <archive> <manifest> <mainClass>my.company.dice.MainKt</mainClass> </manifest> </archive> </configuration> </plugin> </plugins> </build> <properties> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <maven.compiler.source>1.8</maven.compiler.source> <maven.compiler.target>1.8</maven.compiler.target> <kotlin.version>1.3.20</kotlin.version> <kotlin.compiler.incremental>true</kotlin.compiler.incremental> </properties> </project> 

— .


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

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

 java -classpath "path_to_project/DiceCli/target/adventures/*" -jar path_to_project/DiceCli/target/dice-1.0-jar-with-dependencies.jar 

«, ? -jar -classpath !»

. Classpath jar- META-INF/MANIFEST.MF ( — Claspath: ). , ( maven-compiler-plugin , , maven-assembly-plugin ). wildcards , , — jar-. , , .

, . , . adventures/يمكنك رمي أي عدد من المغامرات ، وكلها كانت مرئية لمحرك اللعبة أثناء التنفيذ. لسوء الحظ ، فإن الوظيفة الواضحة تبدو تتجاوز التمثيل القياسي لعالم Java. لذلك ، ليس موضع ترحيب. يجب اتباع نهج مختلف لنشر المغامرة المستقلة. أي واحد؟ لا أعرف ، اكتب التعليقات - بالتأكيد شخص ما لديه أفكار ذكية.

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

في Windows:

 @ECHO OFF call "path_to_maven\mvn.bat" -f "path_to_project\DiceCore\pom.xml" install call "path_to_maven\mvn.bat" -f "path_to_project\DiceCli\pom.xml" package call "path_to_maven\mvn.bat" -f "path_to_project\TestAdventure\pom.xml" package mkdir path_to_project\DiceCli\target\adventures copy "path_to_project\TestAdventure\target\test-adventure-1.0.jar" path_to_project\DiceCli\target\adventures\ chcp 65001 cd path_to_project\DiceCli\target\ java -Dfile.encoding=UTF-8 -cp "dice-cli-1.0-jar-with-dependencies.jar;adventures\*" my.company.dice.MainKt pause 

وعلى يونيكس:

 #!/bin/sh mvn -f "path_to_project/DiceCore/pom.xml" install mvn -f "path_to_project/DiceCli/pom.xml" package mvn -f "path_to_project/TestAdventure/pom.xml" package mkdir path_to_project/DiceCli/target/adventures cp path_to_project/TestAdventure/target/test-adventure-1.0.jar path_to_project/DiceCli/target/adventures/ cd path_to_project/DiceCli/target/ java -cp "dice-cli-1.0-jar-with-dependencies.jar:adventures/*" my.company.dice.MainKt 

وهنا الخدعة. بدلاً من استخدام المفتاح ، -jarنضيف مشروع Cli إلى classpath ونحدد بوضوح الفئة الموجودة فيه كنقطة إدخال MainKt. بالإضافة إلى أننا نقوم بتوصيل جميع الأرشيفات من المجلد adventures/.

لا حاجة للإشارة مرة أخرى إلى أي مدى هذا القرار الملتوي - أنا أعرف ذلك ، شكراً. من الأفضل أن تقترح أفكارك في التعليقات. الرجاء . (ಥ﹏ಥ)

الخطوة السابعة عشرة. قصة


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

, , -, - ( , ?). , ( ), , - , ( , ) ( ). , — .

— .

, , . , : ( ) ( ), . , .

— , . , , .

, , - . , , , , . .

, , , , , , . , . , : , , , — : /, . ( ) (, ).

— , , , ( : , , -, … ). , ( ) , , , … . , , , . , .

? — , , : , — . - , , .

ما التالي؟


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

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

"ولكن لا يزال ، لا تريد التحدث عن رصيد اللعبة؟"

, . — , : « ?». , , : « ?». ( , ) . , (challenge) . , , , - - . , — , . , … .

بمعنى آخر ، يجب أن تكون اللعبة متوازنة. هذا ينطبق بشكل خاص على لعبة اللوح ، حيث يتم إضفاء الطابع الرسمي على القواعد بشكل واضح. كيف نفعل ذلك؟ . -, ( , ) ( ), — playtesting . . — . , , , . — . , , : « feedback!». , - , , — ( , ?) (-).

, … . ( !) — - . (, , ). , — , . .

. . !

«! ? ? , , ?»

.


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

في واجهة وحدة التحكم ، يبدو هذا


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

مخطط النشاط للقائمة الرئيسية


سهل ، أليس كذلك؟ عن ذلك والكلام. الرمز هو أيضا ترتيب أبسط من حيث الحجم.

 class MainMenu( private val renderer: MenuRenderer, private val interactor: MenuInteractor ) { private var actions = ActionList.EMPTY fun start() { Audio.playMusic(Music.MENU_MAIN) actions = ActionList() actions.add(Action.Type.NEW_ADVENTURE) actions.add(Action.Type.CONTINUE_ADVENTURE, false) actions.add(Action.Type.MANUAL, false) actions.add(Action.Type.EXIT) processCycle() } private fun processCycle() { while (true) { renderer.drawMainMenu(actions) when (interactor.pickAction(actions).type) { Action.Type.NEW_ADVENTURE -> TODO() Action.Type.CONTINUE_ADVENTURE -> TODO() Action.Type.MANUAL -> TODO() Action.Type.EXIT -> { Audio.stopMusic() Audio.playSound(Sound.LEAVE) renderer.clearScreen() Thread.sleep(500) return } else -> throw AssertionError("Should not happen") } } } } 

MenuRenderer MenuInteractor , .

 interface MenuRenderer: Renderer { fun drawMainMenu(actions: ActionList) } interface Interactor { fun anyInput() fun pickAction(list: ActionList): Action } 

, . , , Cli ( Droid ), Core . .

Android Studio ( ), , Kotlin. Core , Maven- .

 apply plugin: 'com.android.application' apply plugin: 'kotlin-android' apply plugin: 'kotlin-android-extensions' android { compileSdkVersion 28 defaultConfig { applicationId "my.company.dice" minSdkVersion 14 targetSdkVersion 28 versionCode 1 versionName "1.0" } } dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" implementation "my.company:dice-core:1.0" } 

بشكل افتراضي ، ومع ذلك ، لن يرى أي شخص تبعية لدينا - يجب عليك الإشارة بوضوح إلى الحاجة إلى استخدام مستودع محلي (mavenLocal) عند بناء المشروع.

 buildscript { ext.kotlin_version = '1.3.20' repositories { google() jcenter() mavenLocal() } dependencies { classpath 'com.android.tools.build:gradle:3.3.0' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } allprojects { repositories { google() jcenter() mavenLocal() } } 

, , — . , , : SoundPlayer , MusicPlayer , MenuInteractor ( GameInteractor ), MenuRenderer ( GameRenderer ) StringLoader , , . , .

(, , ) Android — Canvas . - View — «». , , , . View — , ( , ).

View .

 <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="my.company.dice"> <application android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:theme="@style/AppTheme"> <activity android:name=".ui.MainActivity" android:screenOrientation="sensorLandscape" android:configChanges="orientation|keyboardHidden|screenSize"> <intent-filter> <category android:name="android.intent.category.LAUNCHER"/> <action android:name="android.intent.action.MAIN"/> </intent-filter> </activity> </application> </manifest> 

— , . , , .

 <resources> <style name="AppTheme" parent="android:Theme.Black.NoTitleBar.Fullscreen"/> </resources> 

, Cli , :

 <resources> <string name="action_new_adventure_key">N</string> <string name="action_new_adventure_name">ew adventure</string> <string name="action_continue_adventure_key">C</string> <string name="action_continue_adventure_name">ontinue adventure</string> <string name="action_manual_key">M</string> <string name="action_manual_name">anual</string> <string name="action_exit_key">X</string> <string name="action_exit_name">Exit</string> </resources> 

( ), /assets/sound/leave.wav /assets/music/menu_main.mp3 .

, (, ). , , .



, , .

, , — DiceSurfaceView , ( SurfaceViewGlSurfaceView — , , , , ). , : , . .

, Renderer . — View, onDraw() , , , . drawMainMenu() MainMenu ? ?

. DiceSurface instructions — , , onDraw() . Renderer , , . , (strategy). :

 typealias RenderInstructions = (Canvas, Paint) -> Unit class DiceSurface(context: Context) : View(context) { private var instructions: RenderInstructions = { _, _ -> } private val paint = Paint().apply { color = Color.YELLOW style = Paint.Style.STROKE isAntiAlias = true } fun updateInstructions(instructions: RenderInstructions) { this.instructions = instructions this.postInvalidate() } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) canvas.drawColor(Color.BLACK) //Fill background with black color instructions.invoke(canvas, paint) //Execute current render instructions } } class DroidMenuRenderer(private val surface: DiceSurface): MenuRenderer { override fun clearScreen() { surface.updateInstructions { _, _ -> } } override fun drawMainMenu(actions: ActionList) { surface.updateInstructions { c, p -> val canvasWidth = c.width val canvasHeight = c.height //Draw title text p.textSize = canvasHeight / 3f p.strokeWidth = 0f p.color = Color.parseColor("#ff808000") c.drawText( "DICE", (canvasWidth - p.measureText("DICE")) / 2f, (buttonTop - p.ascent() - p.descent()) / 2f, p ) //Other instructions... } } } 

, - Renderer, , View. instructions — , Kotlin .

Interactor. : (), () , . — Looper, , . Interactor - , Activity View , .

BlockingQueue . DroidMenuInteractor take() , , ( Action ). DiceSurface , , ( onTouchEvent() View ), offer() . :

 class DiceSurface(context: Context) : View(context) { private val actionQueue: BlockingQueue<Action> = LinkedBlockingQueue<Action>() fun awaitAction(): Action = actionQueue.take() override fun onTouchEvent(event: MotionEvent): Boolean { if (event.action == MotionEvent.ACTION_UP) { actionQueue.offer(Action(Action.Type.NONE), 200, TimeUnit.MILLISECONDS) } return true } } class DroidMenuInteractor(private val surface: DiceSurface) : Interactor { override fun anyInput() { surface.awaitAction() } override fun pickAction(list: ActionList): Action { while (true) { val type = surface.awaitAction().type list .filter(Action::isEnabled) .find { it.type == type } ?.let { return it } } } } 

, Interactor awaitAction() - , . , . UI- , , , (, ). / .

, , -. , . — Interactor , — Renderer. . DiceSurface — ( , - ). Action . Renderer , onTouchEvent() , , Action .

 private class ActiveRect(val action: Action, left: Float, top: Float, right: Float, bottom: Float) { val rect = RectF(left, top, right, bottom) fun check(x: Float, y: Float, w: Float, h: Float) = rect.contains(x / w, y / h) } 

check() . , Renderer' ( , ) . ( ) 0 1 . , — . .

DiceSurface , ( addRectangle() clearRectangles() ) ( Renderer'), onTouchEvent() , .

 class DiceSurface(context: Context) : View(context) { private val actionQueue: BlockingQueue<Action> = LinkedBlockingQueue<Action>() private val rectangles: MutableSet<ActiveRect> = Collections.newSetFromMap(ConcurrentHashMap<ActiveRect, Boolean>()) private var instructions: RenderInstructions = { _, _ -> } private val paint = Paint().apply { color = Color.YELLOW style = Paint.Style.STROKE isAntiAlias = true } fun updateInstructions(instructions: RenderInstructions) { this.instructions = instructions this.postInvalidate() } fun clearRectangles() { rectangles.clear() } fun addRectangle(action: Action, left: Float, top: Float, right: Float, bottom: Float) { rectangles.add(ActiveRect(action, left, top, right, bottom)) } fun awaitAction(): Action = actionQueue.take() override fun onTouchEvent(event: MotionEvent): Boolean { if (event.action == MotionEvent.ACTION_UP) { with(rectangles.firstOrNull { it.check(event.x, event.y, width.toFloat(), height.toFloat()) }) { if (this != null) { actionQueue.put(action) } else { actionQueue.offer(Action(Action.Type.NONE), 200, TimeUnit.MILLISECONDS) } } } return true } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) canvas.drawColor(Color.BLACK) instructions(canvas, paint) } } 

ConcurrentModificationException , ( ).

DroidMenuInteractor , DroidMenuRenderer . ActionList . DICE, . .

 class DroidMenuRenderer ( private val surface: DiceSurface, private val loader: StringLoader ) : MenuRenderer { protected val helper = StringLoadHelper(loader) override fun clearScreen() { surface.clearRectangles() surface.updateInstructions { _, _ -> } } override fun drawMainMenu(actions: ActionList) { //Prepare rectangles surface.clearRectangles() val percentage = 1.0f / actions.size actions.forEachIndexed { i, a -> surface.addRectangle(a, i * percentage, 0.45f, i * percentage + percentage, 1f) } //Prepare instructions surface.updateInstructions { c, p -> val canvasWidth = c.width val canvasHeight = c.height val buttonTop = canvasHeight * 0.45f val buttonWidth = canvasWidth / actions.size val padding = canvasHeight / 144f //Draw title text p.textSize = canvasHeight / 3f p.strokeWidth = 0f p.color = Color.parseColor("#ff808000") p.isFakeBoldText = true c.drawText( "DICE", (canvasWidth - p.measureText("DICE")) / 2f, (buttonTop - p.ascent() - p.descent()) / 2f, p ) p.isFakeBoldText = false //Draw action buttons p.textSize = canvasHeight / 24f actions.forEachIndexed { i, a -> p.color = if (a.isEnabled) Color.YELLOW else Color.LTGRAY p.strokeWidth = canvasHeight / 240f c.drawRect( i * buttonWidth + padding, buttonTop + padding, i * buttonWidth + buttonWidth - padding, canvasHeight - padding, p ) val name = mergeActionData(helper.loadActionData(a)) p.strokeWidth = 0f c.drawText( name, i * buttonWidth + (buttonWidth - p.measureText(name)) / 2f, (canvasHeight + buttonTop - p.ascent() - p.descent()) / 2f, p ) } } } private fun mergeActionData(data: Array<String>) = if (data.size > 1) { if (data[1].first().isLowerCase()) data[0] + data[1] else data[1] } else data.getOrNull(0) ?: "" } 

StringLoader StringLoadHelper ( ). ResourceStringLoader () . , — .

 class ResourceStringLoader(context: Context) : StringLoader { private val packageName = context.packageName private val resources = context.resources override fun loadString(key: String): String = resources.getString(resources.getIdentifier(key, "string", packageName)) } 

. MediaPlayer , . :

 class DroidMusicPlayer(private val context: Context): MusicPlayer { private var currentMusic: Music? = null private val player = MediaPlayer() override fun play(music: Music) { if (currentMusic == music) { return } currentMusic = music player.setAudioStreamType(AudioManager.STREAM_MUSIC) val afd = context.assets.openFd("music/${music.toString().toLowerCase()}.mp3") player.setDataSource(afd.fileDescriptor, afd.startOffset, afd.length) player.setOnCompletionListener { it.seekTo(0) it.start() } player.prepare() player.start() } override fun stop() { currentMusic = null player.release() } } 

. -, prepare() , ( ) . , prepareAsync() OnPreparedListener . -, (, ), . --…

MediaPlayer , ( ), SoundPool . , , . — ( , ).

 class DroidSoundPlayer(context: Context) : SoundPlayer { private val soundPool: SoundPool = SoundPool(2, AudioManager.STREAM_MUSIC, 100) private val sounds = mutableMapOf<Sound, Int>() private val rate = 1f private val lock = ReentrantReadWriteLock() init { Thread(SoundLoader(context)).start() } override fun play(sound: Sound) { if (lock.readLock().tryLock()) { try { sounds[sound]?.let { s -> soundPool.play(s, 1f, 1f, 1, 0, rate) } } finally { lock.readLock().unlock() } } } private inner class SoundLoader(private val context: Context) : Runnable { override fun run() { val assets = context.assets lock.writeLock().lock() try { Sound.values().forEach { s -> sounds[s] = soundPool.load( assets.openFd("sound/${s.toString().toLowerCase()}.wav"), 1 ) } } finally { lock.writeLock().unlock() } } } } 

Sound . , ReentrantReadWriteLock .

- MainActivity — ? , MainMenu ( Game ) .

 class MainActivity : Activity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) Audio.init(DroidSoundPlayer(this), DroidMusicPlayer(this)) val surface = DiceSurface(this) val renderer = DroidMenuRenderer(surface) val interactor = DroidMenuInteractor(surface, ResourceStringLoader(this)) setContentView(surface) Thread { MainMenu(renderer, interactor).start() finish() }.start() } override fun onBackPressed() { } } 

هذا ، في الواقع ، هو كل شيء. :



, , , .

روابط مفيدة


, . — . , — . , , . ( , ):


- , , : !

launcher ( ). JavaFX OpenJDK ( — ), . readme.txt ( ?). , , , .

, , , - , , , lore , . . , , . .

كل التوفيق.

Source: https://habr.com/ru/post/ar445278/


All Articles