Epigraph:
- كيف سأقيم إذا كنت لا تعرف ماذا تفعل؟
- حسنا ، سيكون هناك الشاشات والأزرار.
- ديما ، لقد وصفت حياتي كلها بثلاث كلمات!
(ج) حوار حقيقي في اجتماع حاشد في شركة ألعاب
تشكلت مجموعة الاحتياجات والحلول التي تلبيها ، والتي سأناقشها في هذه المقالة ، خلال مشاركتي في حوالي 12 مشروعًا كبيرًا ، أولاً على Flash ثم في Unity لاحقًا. يحتوي أكبر المشاريع على أكثر من 200000 وحدة DAU وأكمل بنك أصبع الخاص بي بتحديات أصلية جديدة. من ناحية أخرى ، تم تأكيد أهمية وضرورة النتائج السابقة.
في واقعنا القاسي ، كل شخص قام بتصميم مشروع كبير مرة واحدة على الأقل في أفكاره لديه أفكاره الخاصة حول كيفية القيام بذلك ، وغالبًا ما يكون مستعدًا للدفاع عن أفكاره حتى آخر نقطة من الدم. بالنسبة للآخرين ، يجعلني أبتسم ، وغالبًا ما تنظر الإدارة إلى كل هذا على أنه صندوق أسود ضخم لم يستريح لأي أحد. ولكن ماذا لو قلت لك إن الحلول الصحيحة ستساعد في تقليل إنشاء وظائف جديدة بمقدار 2-3 مرات ، والبحث عن الأخطاء في الفترة من 5 إلى 10 مرات القديمة ، وسوف تسمح لك بالقيام بالعديد من الأشياء الجديدة والهامة التي لم تكن متوفرة من قبل على الإطلاق؟ يكفي أن تدع الهندسة المعمارية في قلبك!
الحلول المعمارية للعبة المحمول. الجزء 2: القيادة وقوائم الانتظارالحلول المعمارية للعبة المحمول. الجزء 3: عرض على قوة الدفعنموذج
الوصول إلى الحقول
يدرك معظم المبرمجين أهمية استخدام شيء مثل MVC. قلة من الناس يستخدمون MVC النقي من كتاب عصابة مكونة من أربعة أشخاص ، لكن جميع قرارات المكاتب العادية تشبه إلى حد ما هذا النمط من حيث الروح. اليوم سنتحدث عن أول الحروف في هذا الاختصار. نظرًا لأن جزءًا كبيرًا من عمل المبرمجين في لعبة محمولة هي ميزات جديدة في اللعبة الوصفية ، حيث يتم تنفيذها على أنها تلاعب بالنموذج ، وتكدس الآلاف من الواجهات في هذه الميزات. ويلعب النموذج دورًا رئيسيًا في هذا الدرس.
لا أقدم الشفرة الكاملة ، لأنها صغيرة الحجم ، وبشكل عام لا تتعلق به. سأشرح منطقي بمثال بسيط:
public class PlayerModel { public int money; public InventoryModel inventory; public void SomeTestChanges() { money = 10; inventory.capacity++; } }
لا يناسبنا هذا الخيار على الإطلاق ، لأن النموذج لا يرسل أحداثًا حول التغييرات التي تحدث فيه. إذا كانت المعلومات المتعلقة بالمجالات التي تأثرت بالتغييرات ، والتي لا تتأثر ، وأيها تحتاج إلى إعادة رسم ، وأيها لا ، فسيحدد المبرمج يدويًا بشكل أو بآخر - سيصبح هذا هو المصدر الرئيسي للأخطاء والوقت المستغرق. وليس من الضروري أن تفاجأ العينين ، ففي معظم المكاتب الكبيرة التي عملت فيها ، أرسل المبرمج جميع أنواع InventoryUpdatedEvent نفسه ، وفي بعض الحالات قام أيضًا بملئها يدويًا. حققت بعض هذه المكاتب الملايين ، هل تعتقد ذلك ، وذلك بفضل أو على الرغم من؟
سوف نستخدم ReactiveProperty فئة <T> الخاصة بنا والتي ستخفي تحت الغطاء كل التلاعب لإرسال الرسائل التي نحتاجها. سيبدو شيء مثل هذا:
public class PlayerModel : Model { public ReactiveProperty<int> money = new ReactiveProperty<int>(); public ReactiveProperty<InventoryModel> inventory = new ReactiveProperty<InventoryModel>(); public void SomeTestChanges() { money.Value = 10; inventory.Value.capacity.Value++; } public void Subscription(Text text) { money.SubscribeWithState(text, (x, t) => t.text = x.ToString()); } }
هذه هي النسخة الأولى من النموذج. هذا الخيار هو حلم للعديد من المبرمجين ، لكنني ما زلت لا أحب ذلك. أول شيء لا يعجبني هو أن الوصول إلى القيم معقد. تمكنت من الشعور بالارتباك أثناء كتابة هذا المثال ، ونسيان القيمة في مكان واحد ، وبالتحديد هذه التلاعب في البيانات التي تشكل حصة الأسد من كل ما يتم القيام به والخلط مع النموذج. إذا كنت تستخدم إصدار اللغة 4.x ، فيمكنك القيام بذلك:
public ReactiveProperty<int> money { get; private set; } = new ReactiveProperty<int>();
ولكن هذا لا يحل جميع المشاكل. أود أن أكتب ببساطة: inventory.capacity ++ ؛. لنفترض أننا نحاول الحصول على كل حقل نموذجي ؛ وضع ولكن من أجل الاشتراك في الأحداث ، نحتاج أيضًا إلى الوصول إلى ReactiveProperty نفسها. واضح الإزعاج ومصدر الارتباك. على الرغم من حقيقة أننا نحتاج فقط إلى الإشارة إلى أي مجال سنقوم بمراقبته. وهنا توصلت إلى مناورة صعبة أحببتها.
دعونا نرى ما اذا كنت ترغب في ذلك.
لم يتم إدخال ReactiveProperty في النموذج الملموس الذي يتعامل معه المبرمج ، بل تم إدراجه ، ولكن واصفه الثابت PValue ، وريث الخاصية الأكثر عمومية ، يحدد الحقل ، وداخل غطاء محرك السيارة منشئ النموذج يتم إخفاء إنشاء وتخزين ReactiveProperty من النوع المرغوب. ليس أفضل اسم ، لكنه حدث ، ثم أعيد تسميته.
في الكود ، يبدو كما يلي:
public class PlayerModel : Model { public static PValue<int> MONEY = new PValue<int>(); public int money { get { return MONEY.Get(this); } set { MONEY.Set(this, value) } } public static PModel<InventoryModel> INVENTORY = new PModel<InventoryModel>(); public InventoryModel inventory { get { return INVENTORY.Get(this); } set { INVENTORY.Set(this, value) } } public void SomeTestChanges() { money = 10; inventory.capacity++; } public void Subscription(Text text) { this.Get(MONEY).SubscribeWithState(text, (x, t) => t.text = x.ToString()); } }
هذا هو الخيار الثاني. بطبيعة الحال ، كان الجد العام للنموذج معقدًا على حساب إنشاء واستخراج ReactiveProperty حقيقي وفقًا لوصفه ، ولكن يمكن القيام بذلك بسرعة كبيرة وبدون تفكير ، أو بالأحرى ، تطبيق الانعكاس مرة واحدة فقط في مرحلة تهيئة الفصل. وهذا هو العمل الذي قام به خالق المحرك مرة واحدة ، وبعد ذلك سيتم استخدامه من قبل الجميع. بالإضافة إلى ذلك ، يتجنب هذا التصميم المحاولات العرضية لمعالجة ReactiveProperty نفسه بدلاً من القيم المخزنة فيه. يتم تشويش إنشاء الحقل ، لكنه في جميع الحالات متماثل تمامًا ، ويمكن إنشاؤه باستخدام قالب.
في نهاية المقال ، يوجد استطلاع للرأي هو الخيار الذي تفضله.
يمكن تنفيذ كل ما هو موضح أدناه في كلا الإصدارين.
المعاملات
أريد أن يكون المبرمجون قادرين على تغيير حقول النماذج فقط عندما يتم السماح بذلك بواسطة القيود المعتمدة في المحرك ، أي داخل الفريق ، وليس مرة أخرى أبدًا. للقيام بذلك ، يجب على المضبط الذهاب إلى مكان والتحقق مما إذا كان أمر المعاملة مفتوحًا حاليًا ، وعندئذٍ فقط السماح بتحرير المعلومات في النموذج. يعد هذا ضروريًا للغاية ، لأن مستخدمي المحرك يحاولون بانتظام القيام بشيء غريب لتجاوز عملية نموذجية ، وكسر منطق المحرك والتسبب في أخطاء طفيفة. رأيت هذا أكثر من مرة أو مرتين.
هناك اعتقاد بأنه إذا قمت بإنشاء واجهة منفصلة لقراءة البيانات من النموذج وللكتابة ، فسوف يساعد ذلك بطريقة أو بأخرى. في الواقع ، لقد امتلأ النموذج بملفات إضافية وعمليات إضافية مملة. هذه القيود نهائية ، فالمبرمجون يضطرون ، أولاً ، إلى معرفة هذه الأفكار والتفكير فيها باستمرار: "ما ينبغي أن تقدمه كل وظيفة أو نموذج أو واجهة معينة" ، وثانياً ، تنشأ المواقف أيضًا عندما يتعين التحايل على هذه القيود ، لذلك في المخرج ، لدينا d'Artagnan ، الذي توصل إلى كل هذا باللون الأبيض ، والعديد من مستخدمي محركه ، الذين يعتبرون حراسًا سيئين لمدير المشروع ، وعلى الرغم من الإساءة المستمرة ، لا يوجد شيء على النحو المنشود. لذلك ، أفضل فقط منع إحكام حدوث مثل هذا الخطأ بإحكام. تقليل جرعة الاتفاقيات ، إذا جاز التعبير.
يجب أن يكون واضع ReactiveProperty رابطًا إلى المكان الذي يجب فيه التحقق من الحالة الحالية للمعاملة. دعنا نقول هذا المكان هو classCModelRoot. أسهل خيار هو تمريره إلى مُنشئ النموذج بشكل صريح. يتلقى الإصدار الثاني من التعليمات البرمجية عند الاتصال بـ RProperty رابطًا لهذا بشكل صريح ، ويمكن الحصول على جميع المعلومات اللازمة من هناك. بالنسبة للإصدار الأول من الكود ، يجب أن تدور حول حقول نوع ReactiveProperty في المنشئ مع انعكاس ومنحهم رابطًا لهذا لمزيد من التلاعب. الإزعاج الطفيف هو الحاجة إلى إنشاء مُنشئ صريح مع معلمة في كل نموذج ، شيء مثل هذا:
public class PlayerModel : Model { public PlayerModel(ModelRoot gamestate) : base (gamestate) {} }
لكن بالنسبة للميزات الأخرى للنماذج ، من المفيد جدًا أن يكون للنموذج ارتباط بالنموذج الأصلي ، مكونًا بنية موصولة بالبيكون. في مثالنا ، سيكون هذا player.inventory.Parent == player. ومن ثم يمكن تجنب هذا المنشئ. سيكون أي نموذج قادرًا على الحصول على رابط إلى مكان سحري من والده وتخزينه مؤقتًا ، وذلك من والده ، وهكذا حتى يتحول الوالد التالي إلى ذلك المكان السحري. نتيجة لذلك ، على مستوى الإعلانات ، سيبدو كل هذا كما يلي:
public class ModelRoot : Model { public bool locked { get; private set; } } public partial class Model { public Model Parent { get; protected set; } public ModelRoot Root { get; } }
سيتم ملء كل هذا الجمال تلقائيًا عندما يدخل النموذج شجرة gamestate. نعم ، النموذج الذي تم إنشاؤه حديثًا ، والذي لم يصل إلى هناك بعد ، لن يكون قادرًا على التعرف على المعاملة وحظر التلاعب بها ، ولكن إذا كانت حالة المعاملة محظورة ، فلن تتمكن من الدخول إلى الولاية بعد ذلك ، فلن يسمح بها واضع الأصل المستقبل ، لذلك لن تتأثر سلامة gamestate. نعم ، سيتطلب ذلك عملًا إضافيًا في مرحلة برمجة المحرك ، ولكن من ناحية أخرى ، فإن المبرمج الذي يستخدم المحرك سوف يلغي تمامًا الحاجة إلى معرفته والتفكير فيه حتى يحاول أن يفعل شيئًا خاطئًا ويضع يديه عليه.
منذ بدء المحادثة حول المعاملات ، لا ينبغي معالجة الرسائل المتعلقة بالتغييرات فور إجراء التغيير ، ولكن فقط عند اكتمال جميع عمليات التلاعب بالنموذج الموجود داخل الأمر الحالي. هناك سببان لذلك ، الأول هو اتساق البيانات: ليست كل حالات البيانات متسقة داخليًا ، وربما لا يمكنك محاولة عرضها. أو إذا كنت غير صبور ، على سبيل المثال ، لفرز صفيف أو تغيير بعض متغير النموذج في حلقة. يجب أن لا تتلقى مئات من رسائل التغيير.
هناك طريقتان للقيام بذلك. الأول هو الاشتراك في تحديثات لأحد المتغيرات واستخدام وظيفة صعبة تضيف مجموعة من نهايات المعاملات إلى دفق التغييرات في المتغير وبعد ذلك فقط سيتخطى الرسائل. هذا سهل بما فيه الكفاية إذا كنت تستخدم UniRX ، على سبيل المثال. لكن هذا الخيار له العديد من أوجه القصور ، ولا سيما أنه يثير الكثير من الحركة غير الضرورية. شخصيا ، أنا أحب الخيار الآخر.
سيتذكر كل ReactiveProperty حالته قبل بدء المعاملة وحالتها الحالية. سيتم إجراء رسالة حول التغيير وإصلاح التغييرات فقط في نهاية المعاملة. في حالة ما إذا كان كائن التغيير عبارة عن نوع من التجميع ، سيسمح ذلك صراحةً بتضمين معلومات حول التغييرات التي حدثت في الرسالة المرسلة ، على سبيل المثال ، تمت إضافة هذين العنصرين في القائمة ، وتم حذف هذين العنصرين. بدلاً من مجرد قول أن شيئًا ما قد تغير ، وإجبار المستلم على تحليل قائمة تضم ألف عنصر في البحث عن المعلومات التي يجب إعادة رسمها.
public partial class Model { public void DispatchChanges(Command transaction); public void FixChanges(); public void RevertChanges(); }
يستهلك هذا الخيار وقتًا أطول في مرحلة إنشاء المحرك ، ولكن تكلفة الاستخدام أقل. والأهم من ذلك ، أنه يفتح إمكانية التحسين التالي.
معلومات حول التغييرات التي تم إجراؤها على النموذج
اريد المزيد من النموذج في أي لحظة أريد أن أرى بسهولة وملاءمة ما الذي تغير في حالة النموذج كنتيجة لأفعالي. على سبيل المثال ، في هذا النموذج:
{"player":{"money":10, "inventory":{"capacity":11}}}
في أغلب الأحيان ، من المفيد للمبرمج أن يرى الفرق بين حالة النموذج قبل بدء الأمر وبعد نهايته ، أو عند نقطة ما داخل الأمر. البعض لهذا استنساخ gamestate بأكمله قبل بداية الفريق ، ثم قارن. هذا يحل المشكلة جزئيًا في مرحلة تصحيح الأخطاء ، لكن من المستحيل تمامًا تشغيل هذا في المنتج. إن استنساخ الحالة هذا ، الذي يحسب الفارق الضئيل بين القائمتين ، يعد عملية باهظة التكلفة للقيام بأي عطس.
لذلك ، يجب على ReactiveProperty تخزين ليس فقط حالته الحالية ، ولكن أيضًا الحالة السابقة. وهذا يؤدي إلى مجموعة كاملة من الفرص المفيدة للغاية. أولاً ، استخراج الفرق في مثل هذا الموقف سريع ، ويمكننا أن نتخلص منه بهدوء في الطعام. ثانياً ، لا يمكنك الحصول على فرق كبير الحجم ، ولكن لا يوجد به سوى القليل من التغييرات المدمجة ، ومقارنته بتجزئة من التغييرات في نفس gamestate. إذا لم توافق ، فهناك مشاكل. ثالثًا ، إذا وقع تنفيذ الأمر مع التنفيذ ، فيمكنك دائمًا إلغاء التغييرات ومعرفة الحالة غير الملوثة في وقت بدء المعاملة. جنبا إلى جنب مع الفريق المطبق على الدولة ، هذه المعلومات لا تقدر بثمن لأنه يمكنك بسهولة إعادة إنتاج الموقف بدقة. بالطبع ، لهذا تحتاج إلى أن تكون لديك وظيفة جاهزة للتسلسل المريح وإلغاء تسلسل حالة اللعبة ، لكنك ستحتاج إليها على أي حال.
تسلسل تغييرات النموذج
يوفر المحرك التسلسل والثنائي ، وفي json - وهذا ليس صدفة. بالطبع ، يشغل التسلسل الثنائي مساحة أقل بكثير ويعمل بشكل أسرع ، وهو أمر مهم ، خاصة أثناء التمهيد الأولي. لكن هذا ليس تنسيقًا قابلاً للقراءة البشرية ، ونحن هنا نصلي من أجل راحة تصحيح الأخطاء. بالإضافة إلى ذلك ، هناك مأزق آخر. عندما تذهب اللعبة إلى منتج ، ستحتاج إلى التبديل باستمرار من إصدار إلى آخر. إذا اتبع المبرمجون بعض الاحتياطات البسيطة ولم يحذفوا أي شيء من حالة اللعبة بشكل غير ضروري ، فلن تشعر بهذا الانتقال. وفي التنسيق الثنائي ، لا توجد أسماء لسلسلة الحقول لأسباب واضحة ، وإذا لم تتطابق الإصدارات ، فسيتعين عليك قراءة الثنائي مع الإصدار القديم من الحالة ، وتصديره إلى شيء أكثر إفادة ، على سبيل المثال ، json نفسه ، ثم استيراده إلى حالة جديدة ، وتصديره إلى الملف الثنائي ، أكتب ، وفقط بعد كل هذا العمل بشكل أكبر كالمعتاد. نتيجة لذلك ، في بعض المشاريع ، تتم كتابة التكوينات إلى ثنائيات نظرًا لأحجام cyclopean الخاصة بهم ، وهم يفضلون بالفعل سحب الحالة ذهابًا وإيابًا في شكل json. تقييم النفقات العامة واختيار لك.
[Flags] public enum ExportMode { all = 0x0, changes = 0x1, serverVerified = 0x2,
توقيع أسلوب التصدير (وضع ExportMode ، خارج بيانات القاموس <سلسلة ، كائن>) ينذر بالخطر إلى حد ما. والأمر هنا هو: عند إجراء تسلسل للشجرة بأكملها ، يمكنك الكتابة مباشرة إلى الدفق ، أو في حالتنا ، إلى JSONWriter ، وهي وظيفة إضافية بسيطة لـ StringWriter. ولكن عندما تقوم بتصدير التغييرات ، فهذا ليس بهذه البساطة ، لأنه عندما تتعمق في شجرة وتذهب إلى أحد الفروع ، فإنك لا تزال لا تعرف ما إذا كنت تريد تصدير أي شيء منها على الإطلاق. لذلك ، في هذه المرحلة توصلت إلى حلين ، أحدهما أكثر بساطة والثاني أكثر تعقيدًا واقتصادية. أبسط واحد هو أنه عند تصدير التغييرات فقط ، تقوم بتحويل جميع التغييرات إلى شجرة من القاموس <سلسلة ، كائن> وقائمة <كائن>. ثم ما حدث ، قم بإطعام مسلسلك المفضل. هذا أسلوب بسيط لا يتطلب الرقص مع الدف. لكن عيبها هو أنه في عملية تصدير التغييرات إلى الكومة ، سيتم تخصيص مكان للمجموعات لمرة واحدة. في الواقع ، لا توجد مساحة كبيرة ، لأن هذا التصدير الكامل يعطي شجرة كبيرة ، ويترك الأمر النموذجي تغييرات قليلة جدًا في الشجرة.
ومع ذلك ، يعتقد الكثير من الناس أن إطعام جامع القمامة لأن هذا القزم ليس ضروريًا دون الحاجة القصوى. بالنسبة لهم ، ولتهدئة ضميري ، أعددت حلاً أكثر تعقيدًا:
public partial class Model { public void ExportAll(ExportMode mode, Type propertyType, JSONWriter writer, bool newModel = false); public bool DetectChanges(ExportMode mode, Stack<Model> ierarchyChanged = null); public void ExportChanges(ExportMode mode, Type propertyType, JSONWriter writer, Queue<Model> ierarchyChanges = null); }
جوهر هذه الطريقة هو المشي من خلال الشجرة مرتين. لأول مرة ، استعرض كل النماذج التي غيرت نفسها ، أو لديها تغييرات في النماذج الفرعية ، واكتبها جميعًا في قائمة الانتظار <Model> ierarchyChanges بالترتيب الذي تظهر به في الشجرة في حالتها الحالية تمامًا. لا توجد العديد من التغييرات ، قائمة الانتظار لن تكون طويلة. بالإضافة إلى ذلك ، لا يوجد شيء يمنع الاحتفاظ بـ Stack <Model> و Queue <Model> بين المكالمات وبعد ذلك سيكون هناك عدد قليل جدًا من التخصيصات أثناء المكالمة.
وبمرور الوقت بالفعل للشجرة الثانية ، سيكون من الممكن النظر إلى أعلى قائمة الانتظار في كل مرة ، وفهم ما إذا كان من الضروري الدخول إلى هذا الفرع من الشجرة أو الانتقال فورًا. هذا يسمح لـ JSONWriter بالكتابة على الفور دون إرجاع أي نتائج وسيطة أخرى.
من المحتمل جدًا أن هذا التعقيد ليس ضروريًا حقًا ، لأنه في وقت لاحق سترى أن تصدير التغييرات إلى الشجرة التي تحتاجها فقط لتصحيح الأخطاء أو عند تعطلها مع استثناء. أثناء التشغيل العادي ، يقتصر كل شيء على GetHashCode (وضع ExportMode ، رمز int) الذي تكون كل هذه المسرات غريبة عليه بعمق.
قبل أن نواصل تعقيد نموذجنا ، دعنا نتحدث عن هذا.
لماذا هو مهم جدا
يقول جميع المبرمجين أن هذا أمر مهم للغاية ، لكن عادة لا يصدقهم أحد. لماذا؟
أولاً ، لأن جميع المبرمجين يقولون إنك بحاجة إلى التخلص من القديم والكتابة في الجديد. هذا كل شيء ، بغض النظر عن المؤهلات. لا توجد طريقة إدارية لمعرفة ما إذا كان هذا صحيحًا أم لا ، وعادة ما تكون التجارب باهظة الثمن. سيضطر المدير إلى اختيار مبرمج واحد والثقة بحكمه. المشكلة هي أن مثل هذا المستشار هو عادة الشخص الذي عملت معه الإدارة لفترة طويلة ويقيمها من خلال ما إذا كانت قادرة على إدراك أفكارها ، وكل أفكارها الأفضل تتجسد بالفعل في الواقع. لذلك ليست هذه أيضًا طريقة مثالية لمعرفة مدى جودة أفكار الآخرين ومختلفاتهم.
ثانيا ، 80 ٪ من جميع الألعاب المحمولة تجلب أقل من 500 دولار في حياتهم كلها. لذلك ، في بداية المشروع ، تواجه الإدارة مشكلات أخرى ، أهمها الهندسة المعمارية. لكن القرارات المتخذة في بداية المشروع تأخذ الناس رهائن ولا تتركهم من ستة أشهر إلى ثلاث سنوات. إن عملية إعادة البناء والتحول إلى أفكار أخرى في مشروع يعمل بالفعل ، ولديه عملاء أيضًا ، هي عملية صعبة للغاية ومكلفة ومحفوفة بالمخاطر. إذا كان الاستثمار لمدة ثلاثة أشهر في بنية طبيعية بالنسبة لمشروع ما في البداية يبدو وكأنه رفاهية غير مقبولة ، فماذا يمكنك أن تقول عن تكلفة تأخير التحديث بميزات جديدة لبضعة أشهر؟
ثالثًا ، حتى لو كانت فكرة "كيف ينبغي أن تكون" بحد ذاتها فكرة جيدة ومثالية ، فليس معروفًا كم سيستغرق تنفيذها. اعتماد الوقت الذي يقضيه على برودة مبرمج غير الخطية للغاية. سيجعل المشرف مهمة بسيطة لا أسرع بكثير من المبتدئين. مرة ونصف ، ربما. لكن لكل مبرمج "حد التعقيد" الخاص به ، والذي تتعدى فعاليته بشكل كبير. كانت لدي حالة في حياتي عندما كنت بحاجة إلى تحقيق مهمة معمارية معقدة إلى حد ما ، وحتى التركيز بالكامل على مشكلة إيقاف تشغيل الإنترنت في المنزل وطلب وجبات جاهزة لمدة شهر لم يساعد ، ولكن بعد عامين ، بعد قراءة الكتب المهمة وحل المشكلات ذات الصلة ، لقد حللت هذه المشكلة في ثلاثة أيام. أنا متأكد من أن الجميع سيتذكرون شيئًا كهذا في حياتهم المهنية. وهنا هو الصيد! والحقيقة هي أنه في حالة وجود فكرة بارعة في ذهنك كما ينبغي أن تكون ، فمن المرجح أن هذه الفكرة الجديدة تقع في مكان ما على حدود التعقيد الشخصية الخاصة بك ، وربما حتى وراء ذلك قليلاً. الإدارة ، بعد أن أحرقت مرارا وتكرارا ، بدأت تهب على أي أفكار جديدة. وإذا صنعت اللعبة لنفسك ، فقد تكون النتيجة أسوأ ، لأنه لن يكون هناك أحد يمنعك.
ولكن كيف ، إذن ، أي شخص حتى يتمكن من استخدام حلول جيدة؟ هناك عدة طرق.
أولاً ، تريد كل شركة توظيف شخص جاهز قام بالفعل بهذا مع صاحب عمل سابق. هذه هي الطريقة الأكثر شيوعًا لتحويل عبء التجربة إلى شخص آخر.
ثانياً ، الشركات أو الأشخاص الذين قاموا بأول لعبة ناجحة لهم ، ومشروعون ، وبدء المشروع التالي مستعدون للتغيير.
ثالثًا ، أعترف بصدق لنفسك أنك في بعض الأحيان تفعل شيئًا ليس من أجل الراتب ، ولكن من أجل متعة العملية. الشيء الرئيسي هو إيجاد الوقت لذلك.رابعًا ، إنها مجموعة من الحلول والمكتبات التي أثبتت جدواها ، إلى جانب الأشخاص ، تشكل الأموال الرئيسية لشركة الألعاب ، وهذا هو الشيء الوحيد الذي سيبقى فيها عندما يستقيل شخص رئيسي وينتقل إلى أستراليا.السبب الأخير ، وإن لم يكن السبب الأكثر وضوحًا: لأنه مفيد بشكل رهيب. تؤدي الحلول الجيدة إلى تقليل الوقت اللازم لكتابة ميزات جديدة وتصحيحها واكتشاف الأخطاء. اسمحوا لي أن أقدم لكم مثالاً: منذ يومين ، كان العميل قد نفذ عملية في ميزة جديدة ، واحتمال حدوثها هو واحد من أصل 1000 ، أي أنه سيتم تعذيب ضمان الجودة لإعادة إنتاجه ، وعندما تعطيه ، فإنه يتم إرسال 200 رسالة خطأ في اليوم. كم من الوقت سيستغرق إعادة إنتاج الموقف والتقاط العميل عند نقطة توقف واحدة قبل أن ينهار كل شيء؟ على سبيل المثال ، لدي 10 دقائق.نموذج
شجرة النموذج
يتكون النموذج من العديد من الأشياء. يقرر المبرمجون المختلفون بطريقة مختلفة كيفية توصيلهم معًا. الطريقة الأولى هي عندما يتم تحديد النموذج حسب المكان الذي يقع فيه. هذا مريح للغاية وبسيط عندما ينتمي المرجع إلى النموذج إلى مكان واحد في ModelRoot. ربما يمكن نقلها من مكان إلى آخر ، ولكن لا يؤدي وجود ارتباطين من أماكن مختلفة إلى ذلك. سنفعل ذلك من خلال تقديم إصدار جديد من واصف ModelProperty والذي سيتعامل مع الروابط من نموذج إلى النماذج الأخرى الموجودة فيه. في الكود ، سيبدو كما يلي: public class PModel<T> : Property<T> where T:Model {} public partial class PlayerModel : Model { public PModel<InventoryModel> INVENTORY = new PModel<InventoryModel>(); public InventoryModel inventory { get { return INVENTORY.Value(this); } set { INVENTORY.Value(this, value); } } }
ما هو الفرق؟ عند إضافة نموذج جديد إلى هذا الحقل ، تتم كتابة النموذج الذي تمت إضافته إليه في حقل الأصل ، وعند إعادة حذفه ، تتم إعادة تعيين حقل الأصل. من الناحية النظرية ، كل شيء على ما يرام ، ولكن هناك العديد من المزالق. الأول - المبرمجين الذين سوف يستخدمونه ، يمكن أن يكون مخطئا. لتجنب ذلك ، نفرض عمليات فحص مخفية على هذه العملية ، من زوايا مختلفة:- سنصلح PValue حتى يتحقق من نوع قيمته ، ويقسم الخبراء عند محاولة تخزين إشارة إلى النموذج الموجود فيه ، مع الإشارة إلى أنه من الضروري استخدام بنية مختلفة ، فقط لعدم الخلط. هذا ، بطبيعة الحال ، هو فحص وقت التشغيل ، لكنه يقسم في أول محاولة للبدء ، لذلك سيفعل.
- PModel Parent - , . . , .
ينشأ تأثير جانبي من هذا ، إذا كنت بحاجة إلى تحويل مثل هذا النموذج من مكان إلى آخر ، فيجب عليك أولاً إزالته من المقام الأول ، وعندئذٍ فقط إضافته إلى الثاني - وإلا فإن الشيكات ستوبخك. ولكن هذا يحدث في الواقع نادرا جدا.نظرًا لأن النموذج يقع في مكان محدد بدقة وله إشارة إلى الأصل ، فيمكننا إضافة طريقة جديدة إليه - يمكنه تحديد الطريقة التي يوجد بها في شجرة ModelRoot. يعد هذا الأمر مناسبًا للغاية للتصحيح ، ولكنه ضروري أيضًا حتى يمكن التعرف عليه بشكل فريد. على سبيل المثال ، ابحث عن نموذج آخر تمامًا في لعبة gamestate أخرى ، أو حدد في الأمر الذي تم إرساله إلى الخادم ارتباطًا بالنموذج الذي يحتوي عليه الأمر. يبدو شيء مثل هذا: public class ModelPath { public Property[] properties; public Object[] indexes; public override ToString(); public static ModelPath FromString(string path); } public partial class Model { public ModelPath Path(); } public partial class ModelRoot : Model { public Model GetByPath(ModelPath path); }
ولماذا ، في الواقع ، من المستحيل وجود كائن متجذر في مكان ما ، والإشارة إليه من مكان آخر؟ ولأنك تتخيل أنك تقوم بإلغاء تحديد تسلسل كائن من JSON ، ستجد هنا رابطًا لكائن ذي جذر في مكان مختلف تمامًا. وليس هناك مكان لذلك ، سيتم إنشاؤه فقط من خلال أرضية إلغاء التسلسل. عفوًا من فضلك لا تقدم أي إلغاء تسلسل متعدد التمرير. هذا هو الحد من هذه الطريقة. لذلك ، سنتوصل إلى طريقة ثانية:يتم إنشاء جميع النماذج التي تم إنشاؤها بواسطة الطريقة الثانية في مكان سحري واحد ، وفي جميع الأماكن الأخرى في gamestate يتم إدراج الروابط فقط لهم. أثناء إلغاء التسلسل ، إذا كان هناك العديد من الإشارات إلى الكائن ، في المرة الأولى التي تصل فيها إلى المكان السحري ، يتم إنشاء الكائن ، ويتم إرجاع جميع الإشارات التالية إلى نفس الكائن. لتنفيذ ميزات أخرى ، نفترض أن اللعبة يمكن أن تحتوي على عدة gamestates ، لذلك يجب ألا يكون المكان السحري مشتركًا ، ولكن يجب أن يكون موجودًا ، على سبيل المثال ، في gamestate. للإشارة إلى هذه النماذج ، نستخدم صيغة أخرى للواصف PPersistent. النموذج نفسه سوف يكون أكثر خصوصية من قبل Persistent: Model. في الكود ، سيبدو كالتالي: public class Persistent : Model { public int id { get { return ID.Get(this); } set { ID.Set(this, value); } } public static RProperty<int> ID = new RProperty<int>(); } public partial class ModelRoot : Model { public int nextFreePersistentId { get { return NEXT_FREE_PERSISTENT_ID.Get(this); } set { NEXT_FREE_PERSISTENT_ID.Set(this, value); } } public static RProperty<int> NEXT_FREE_PERSISTENT_ID = new RProperty<int>(); public static PDictionaryModel<int, Persistent> PERSISTENT = new PDictionaryModel<int, Persistent>() { notServerVerified = true };
مرهقة بعض الشيء ، ولكن يمكن استخدامه. لوضع قش ، يمكن لـ Persistent تثبيت المُنشئ باستخدام معلمة ModelRoot ، والتي ستثير إنذارًا إذا ما حاولوا إنشاء هذا النموذج ليس من خلال أساليب هذا ModelRoot.لدي الخياران في الكود الخاص بي ، والسؤال هو ، لماذا إذاً استخدم الخيار الأول إذا كان الثاني يغطي بالكامل جميع الحالات المحتملة؟الجواب هو أن حالة اللعبة يجب أن تكون ، أولاً وقبل كل شيء ، قابلة للقراءة من قبل الناس. كيف تبدو إذا كان الخيار الأول مستخدمًا ، إن أمكن؟ { "persistents":{}, "player":{ "money":10, "inventory":{"capacity":11} } }
والآن ، كيف سيكون شكل الخيار الثاني فقط: { "persistents":{ "1":{"money":10, "inventory":2}, "2":{"capacity":11} }, "player":1 }
لتصحيح شخصيا ، أنا أفضل الخيار الأول.خصائص نموذج الوصول
تبين أن الوصول إلى مرافق التخزين التفاعلية للممتلكات في النهاية كان مخفيًا تحت غطاء النموذج. ليس من الواضح كيف تجعلها تعمل بحيث تعمل بسرعة ، دون الكثير من التعليمات البرمجية في النماذج النهائية ودون الكثير من التفكير. دعنا نلقي نظرة فاحصة.أول شيء مفيد لمعرفة القاموس هو أن القراءة منه لا تستغرق الكثير من الوقت الثابت ، بغض النظر عن حجم القاموس. سننشئ قاموسًا ثابتًا خاصًا في النموذج يتم فيه تعيين كل نوع من أنواع النماذج وصفًا للحقول الموجودة فيه وسنصل إليه مرة واحدة عند إنشاء النموذج. في مُنشئ الكتابة ، نتطلع إلى معرفة ما إذا كان هناك وصف لنوعنا ، وإذا لم يكن الأمر كذلك ، فإننا ننشئه ، إذا كان الأمر كذلك ، فإننا نأخذ الوصف النهائي. وبالتالي ، سيتم إنشاء الوصف مرة واحدة فقط لكل فصل. عند إنشاء وصف ، نضع في كل خاصية ثابتة (وصف الحقل) البيانات المستخرجة من خلال الانعكاس - اسم الحقل ، والفهرس الذي سيتم بموجبه تخزين البيانات لهذا الحقل في الصفيف. بهذه الطريقةعند الوصول إليه من خلال وصف الحقل ، سيتم إخراج التخزين الخاص به من الصفيف في فهرس معروف مسبقًا ، أي بسرعة.في الكود ، سيبدو كما يلي: public class Model : IModelInternals { #region Properties protected static Dictionary<Type, Property[]> propertiesDictionary = new Dictionary<Type, Property[]>(); protected static Dictionary<Type, Property[]> propertiesForBinarySerializationDictionary = new Dictionary<Type, Property[]>(); protected Property[] _properties, _propertiesForBinarySerialization; protected BaseStorage[] _storages; public Model() { Type targetType = GetType(); if (!propertiesDictionary.ContainsKey(targetType)) RegisterModelsProperties(targetType, new List<Property>(), new List<Property>()); _properties = propertiesDictionary[targetType]; _storages = new BaseStorage[_properties.Length]; for (var i = 0; i < _storages.Length; i++) _storages[i] = _properties[i].CreateStorage(); } private void RegisterModelsProperties(Type target, List<Property> registered, List<Property> registeredForBinary) { if (!propertiesDictionary.ContainsKey(target)) { if (target.BaseType != typeof(Model) && typeof(Model).IsAssignableFrom(target.BaseType)) RegisterModelsProperties(target.BaseType, registered, registeredForBinary); var fields = target.GetFields(BindingFlags.Public | BindingFlags.Static); // | BindingFlags.DeclaredOnly List<Property> alphabeticSorted = new List<Property>(); for (int i = 0; i < fields.Length; i++) { var field = fields[i]; if (typeof(Property).IsAssignableFrom(field.FieldType)) { var prop = field.GetValue(this) as Property; prop.Name = field.Name; prop.Parent = target; prop.storageIndex = registered.Count; registered.Add(prop); alphabeticSorted.Add(prop); } } alphabeticSorted.Sort((p1, p2) => String.Compare(p1.Name, p2.Name)); registeredForBinary.AddRange(alphabeticSorted); Property[] properties = new Property[registered.Count]; for (int i = 0; i < registered.Count; i++) properties[i] = registered[i]; propertiesDictionary.Add(target, properties); properties = new Property[registered.Count]; for (int i = 0; i < registeredForBinary.Count; i++) properties[i] = registeredForBinary[i]; propertiesForBinarySerializationDictionary.Add(target, properties); } else { registered.AddRange(propertiesDictionary[target]); registeredForBinary.AddRange(propertiesForBinarySerializationDictionary[target]); } } CastType IModelInternals.GetStorage<CastType>(Property property) { try { return (CastType)_storages[property.storageIndex]; } catch { UnityEngine.Debug.LogError(string.Format("{0}.GetStorage<{1}>({2})",GetType().Name, typeof(CastType).Name, property.ToString())); return null; } } #endregion }
التصميم بسيط بعض الشيء ، لأن واصفات الخصائص الثابتة المعلنة في أسلاف هذا النموذج قد تحتوي بالفعل على فهارس تخزين مسجّلة ، وترتيب إعادة الخصائص من Type.GetFields () غير مضمون .لترتيب ذلك ، ولا يتم إعادة تهيئة الخصائص في اثنين مرات ، تحتاج إلى مراقبة نفسك.خصائص المجموعة
في القسم الموجود على شجرة النموذج ، يمكن للمرء أن يلاحظ إنشاء لم يتم ذكره مسبقًا: PDictionaryModel <int، Persistent> - واصف لحقل يحتوي على مجموعة. من الواضح أنه سيتعين علينا إنشاء مستودع خاص بنا للمجموعات ، والذي يخزن معلومات حول كيفية نظر المجموعة قبل بدء المعاملة وما يبدو عليه الآن. حجم الحصاة الموجود تحت الماء هنا هو حجم Thunder-Stone تحت Peter I. وهو يتكون من وجود قواميس طويلة في متناول اليد ، وهي مهمة مكلفة لحساب الفرق بينهما. أفترض أن مثل هذه النماذج يجب أن تستخدم لجميع المهام المتعلقة بفوقية ، مما يعني أنها يجب أن تعمل بسرعة. بدلاً من تخزين حالتين ، استنساخهما ، ثم مقارنتهما بتكلفة باهظة ، أقوم بإجراء ربط خطير - يتم تخزين الحالة الحالية فقط من القاموس في المتجر.والقيم القديمة للعناصر التي تم استبدالها. أخيرًا ، يتم تخزين مجموعة من المفاتيح الجديدة المضافة إلى القاموس. يتم ملء هذه المعلومات بسهولة وسرعة. من السهل إنشاء جميع الاختلافات اللازمة معها ، وهي كافية لاستعادة الحالة السابقة إذا لزم الأمر. في الكود ، يبدو كما يلي: public class DictionaryStorage<TKey, TValues> : BaseStorage { public Dictionary<TKey, TValues> current = new Dictionary<TKey, TValues>(); public Dictionary<TKey, TValues> removed = new Dictionary<TKey, TValues>(); public Dictionary<TKey, TValues> changedValues = new Dictionary<TKey, TValues>(); public HashSet<TKey> newKeys = new HashSet<TKey>(); }
لم أفلح في الوصول إلى مستودع جميل بنفس الدرجة من القائمة ، أو لم يكن لدي وقت كافي ، أحتفظ بنسختين. هناك حاجة إلى وظيفة إضافية إضافية لمحاولة تقليل حجم الفرق. public class ListStorage<TValue> : BaseStorage { public List<TValue> current = new List<TValue>(); public List<TValue> previouse = new List<TValue>();
المجموع
إذا كنت تعرف بوضوح ما تريد أن تتلقاه وكيف ، يمكنك كتابة كل هذا في غضون أسابيع قليلة. تتغير سرعة تطوير اللعبة في نفس الوقت بشكل كبير لدرجة أنه عندما جربتها ، لم أكن حتى أبدأ ألعاب صنع الألعاب الخاصة بي دون وجود محرك جيد. فقط لأنه في الشهر الأول من الواضح أن الاستثمار فيها قد أتاح لي ثماره. بالطبع ، هذا ينطبق فقط على الفوقية. يجب أن يتم اللعب بالطريقة القديمة.في الجزء التالي من المقالة ، سأتحدث عن الأوامر والشبكات والتنبؤ باستجابات الخادم. ولدي أيضا بعض الأسئلة بالنسبة لك والتي هي مهمة جدا بالنسبة لي. إذا كانت إجاباتك تختلف عن تلك الواردة بين قوسين ، فسوف أقرأها بكل سرور في التعليقات أو ربما تكتب مقالة. شكرا مقدما على الإجابات.PS اقتراح للتعاون وتعليمات حول العديد من أخطاء بناء الجملة ، يرجى في PM.