Blitz Engine & Battle Prime: ECS and Network Code



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

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

هذا هو أحد الأسباب التي تجعلنا نريد أيضًا المساهمة في القضية المشتركة - وهذه المقالة هي واحدة من أولى المقالات المكرسة للتفاصيل التقنية لتطوير محرك Blitz وتشغيله - Battle Prime.

سيتم تقسيم المقال إلى قسمين:

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

تحت خفض الكثير من ميغابايت من متحركة!

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

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

ECS


داخل المحرك ، نستخدم مصطلح "العالم" لوصف مشهد يحتوي على تسلسل هرمي للكائنات.

تعمل العوالم وفقًا لقالب مكون النظام ( الوصف على ويكيبيديا ):

  • الكيان - كائن داخل المشهد. إنه مستودع لمجموعة من المكونات. الكائنات يمكن أن تكون متداخلة ، وتشكيل تسلسل هرمي داخل العالم ؛
  • المكون - هو البيانات اللازمة لتشغيل أي ميكانيكا ، والتي تحدد سلوك الكائن. على سبيل المثال ، يحتوي `TransformComponent` على تحويل الكائن ، و` DynamicBodyComponent` يحتوي على بيانات للمحاكاة المادية. قد لا تحتوي بعض المكونات على بيانات إضافية ، ويصف وجودها البسيط في الكائن حالة هذا الكائن. على سبيل المثال ، في Battle Prime ، يتم استخدام `AliveComponent` و` DeadComponent` ، والتي تحدد الأحرف الحية والميتة ، على التوالي ؛
  • النظام - مجموعة تسمى بشكل دوري مجموعة من الوظائف التي تدعم حل مهمتها. مع كل مكالمة ، يعالج النظام الكائنات التي تفي ببعض الشروط (عادةً ما تحتوي على مجموعة معينة من المكونات) ، وإذا لزم الأمر ، يعدلها. يتم تنفيذ كل منطق اللعبة ومعظم المحرك على مستوى النظام. على سبيل المثال ، يوجد داخل المحرك "LodSystem" يقوم بحساب مؤشرات LOD (مستوى التفاصيل) لكائن بناءً على تحوله في العالم وبيانات أخرى. ثم يتم استخدام هذا الفهرس الموجود في `LodComponent` بواسطة أنظمة أخرى لمهامها.

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

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

انعكاس


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

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

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

تستخدم العديد من الوحدات داخل المحرك الانعكاس لأغراضها الخاصة. بعض الأمثلة:

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

نحن نستخدم تطبيقنا الخاص ، والذي لا تختلف واجهة المستخدم كثيرًا عن الحلول الأخرى الموجودة (على سبيل المثال ، github.com/rttrorg/rttr ). باستخدام مثال CapturePointComponent (الذي يصف نقطة الالتقاط لوضع اللعبة) ، تبدو إضافة الانعكاس إلى النوع كما يلي:

//     class CapturePointComponent final : public Component { //            BZ_VIRTUAL_REFLECTION(Component); public: float points_to_own = 10.0f; String visible_name; // …   }; //   .cpp  BZ_VIRTUAL_REFLECTION_IMPL(CapturePointComponent) { //       ReflectionRegistrar::begin_class<CapturePointComponent>() [M<Serializable>(), M<Scriptable>(), M<DisplayName>("Capture point")] //      .field("points_to_own", &CapturePointComponent::points_to_own) [M<Serializable>(), M<DisplayName>("Points to own")] .field("visible_name", &CapturePointComponent::visible_name) [M<Serializable>(), M<DisplayName>("Name")] // …     } 

أود إيلاء اهتمام خاص للبيانات الوصفية للأنواع والحقول والأساليب التي يتم الإعلان عنها باستخدام التعبير

 M<T>() 

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

وصف المكونات وإضافتها إلى الكائن


يعد كل مكون مورثًا للفئة الأساسية "المكون" ويمكنه أن يصف بمساعدة الانعكاس الحقول التي يستخدمها (إذا لزم الأمر).

هكذا يتم تعريف "AvatarHitComponent" ووصفها داخل اللعبة:

 /** Component that indicates avatar hit event. */ class AvatarHitComponent final : public Component { BZ_VIRTUAL_REFLECTION(Component); public: PlayerId source_id = NetConstants::INVALID_PLAYER_ID; PlayerId target_id = NetConstants::INVALID_PLAYER_ID; HitboxType hitbox_type = HitboxType::UNKNOWN; }; BZ_VIRTUAL_REFLECTION_IMPL(AvatarHitComponent) { ReflectionRegistrar::begin_class<AvatarHitComponent>() .ctor_by_pointer() .copy_ctor_by_pointer() .field("source_id", &AvatarHitComponent::source_id)[M<Replicable>()] .field("target_id", &AvatarHitComponent::target_id)[M<Replicable>()] .field("hitbox_type", &AvatarHitComponent::hitbox_type)[M<Replicable>()]; } 

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

 Entity hit_entity = world->create_entity(); auto* const avatar_hit_component = hit_entity.add<AvatarHitComponent>(); avatar_hit_component->source_id = source_player_id; avatar_hit_component->target_id = target_player_id; avatar_hit_component->hitbox_type = hitbox_type; //      //      // ... 

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

وصف النظم وعملهم


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

على غرار المكونات التي تصف حقولهم ، يصف كل نظام الطرق التي يجب أن يقوم بها العالم.

على سبيل المثال ، يتم الإعلان عن نظام ExplosiveSystem المسؤول عن التفجيرات ووصفه كما يلي:

 // System responsible for handling explosive components: // - tracking when they need to be exploded: by timer, trigger zone etc. // - destroying them on explosion and creating separate explosion entity class ExplosiveSystem final : public System { BZ_VIRTUAL_REFLECTION(System); public: ExplosiveSystem(World* world); private: void update(float dt); //    ,     // ... }; BZ_VIRTUAL_REFLECTION_IMPL(ExplosiveSystem) { ReflectionRegistrar::begin_class<ExplosiveSystem>()[M<SystemTags>("battle")] .ctor_by_pointer<World*>() .method("ExplosiveSystem::update", &ExplosiveSystem::update)[M<SystemTask>( TaskGroups::GAMEPLAY_END, ReadAccess::set< TimeSingleComponent, WeaponDescriptorComponent, BallisticComponent, ProjectileComponent, GrenadeComponent>(), WriteAccess::set<ExplosiveComponent>(), InitAccess::set<ExplosiveStatsComponent, LocalExplosionComponent, ServerExplosionComponent, EntityWasteComponent, ReplicationComponent, AbilityIdComponent, WeaponBaseStatsComponent, HitDamageStatsComponent, ClusterGrenadeStatsComponent>(), UpdateType::FIXED, Vector<TaskOrder>{ TaskOrder::before(FastName{ "ballistic_update" }) })]; } 

يشار إلى البيانات التالية داخل وصف النظام:

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

سيتم توضيح المزيد من المعلومات حول مجموعات الأنظمة والتبعيات وأنواع التحديث أدناه.

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

 void ExplosiveSystem::update(float dt) { const auto* time_single_component = world->get<TimeSingleComponent>(); // Init new explosives for (Component* component : new_explosives_group->components) { auto* explosive_component = static_cast<ExplosiveComponent*>(component); init_explosive(explosive_component, time_single_component); } new_explosives_group->components.clear(); // Update all explosives for (ExplosiveComponent* explosive_component : explosives_group) { update_explosive(explosive_component, time_single_component, dt); } } 

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

 ExplosiveSystem::ExplosiveSystem(World* world) : System(world) { // `explosives_group`        `ExplosiveComponent` explosives_group = world->acquire_component_group<ExplosiveComponent>(); // `new_explosives_group`        //  `ExplosiveComponent` -       new_explosives_group = explosive_group->acquire_component_group_on_add(); } 

تحديث العالم


العالم ، كائن من نوع "العالم" ، كل إطار يستدعي الأساليب اللازمة في عدد من النظم. الأنظمة التي سيتم استدعاؤها تعتمد على نوعها.

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



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

في GIF أدناه ، يعمل العالم بتردد 5 تحديثات ثابتة في الثانية. يمكنك ملاحظة التأخير بين الضغط على الزر W وبدء الحركة ، وكذلك التأخير بين تحرير الزر ووقف حركة الشخصية:

صورة

في الصورة التالية ، يعمل العالم على تردد 30 تحديثًا ثابتًا في الثانية ، مما يوفر تحكمًا أكثر استجابة:

صورة

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

أمر تنفيذ النظام


واحدة من الأشياء التي تعقد العمل مع ECS هي مهمة تنفيذ النظم. بالنسبة للسياق ، في وقت كتابة هذا التقرير ، في عميل Battle Prime أثناء المعركة بين اللاعبين ، هناك نظام 251 وعددهم في ازدياد فقط.

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

يمكن تعيين ترتيب تنفيذ الأنظمة بطرق مختلفة ، على سبيل المثال:

  • ترتيب صريح
  • بيان "الأولوية" العددية للنظام والفرز اللاحق حسب الأولوية ؛
  • قم تلقائيًا بإنشاء رسم بياني للتبعيات بين الأنظمة وتثبيتها في الأماكن الصحيحة في أمر التنفيذ.

في الوقت الحالي ، نحن نستخدم الخيار الثالث. يشير كل نظام إلى المكونات التي يستخدمها للقراءة ، وأيها للكتابة والمكونات التي ينشئها. بعد ذلك ، يتم ترتيب الأنظمة تلقائيًا فيما بينها بالترتيب اللازم:
  • يأتي مكون قراءة النظام A بعد كتابة النظام إلى المكون A ؛
  • يأتي النظام الذي يكتب أو يقرأ المكون B بعد النظام الذي ينشئ المكون B ؛
  • إذا كان كلا النظامين يكتبان إلى المكون C ، فيمكن أن يكون الترتيب (لكن يمكن تحديده يدويًا إذا لزم الأمر).

من الناحية النظرية ، يقلل هذا الحل من التحكم في أمر التنفيذ ؛ كل ما هو مطلوب هو تعيين أقنعة مكونة للنظام. في الممارسة العملية ، مع نمو المشروع ، هذا يؤدي إلى دورات أكثر وأكثر بين النظم. إذا كان النظام 1 يكتب إلى المكون A ، ويقرأ المكون B ، ويقرأ النظام 2 المكون A ويكتب إلى المكون B ، فهذه دورة ، ويجب حلها يدويًا. في كثير من الأحيان ، هناك أكثر من نظامين في دورة. حلها يتطلب وقتا ومؤشرات واضحة للعلاقة بينهما.

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

يوجد حاليًا 10 مجموعات في Battle Prime. هذا لا يزال غير كافٍ ، ونحن نخطط لزيادة عددهم من خلال بناء تسلسل منطقي صارم بينهما ، واستخدام البناء التلقائي للرسم البياني داخل كل منهم.

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

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

صورة

التواصل بين النظم وتكوينها


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

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

قد يكون النهج القائم على المكون غير مريح في بعض الحالات:

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

لحل هذه المشكلات ، نستخدم الطريقة التي استعارناها من فريق تطوير Overwatch - مكونات فردية.

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

في الوقت الحالي ، يحتوي المشروع (وحدات المحرك + اللعبة) على حوالي 120 مكونًا منفردًا تستخدم لأغراض مختلفة - من تخزين البيانات العالمية حول العالم إلى تكوين الأنظمة الفردية.

"النظيفة" النهج


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

يمكن تسليط الضوء على الحجج التالية المؤيدة لنهج أقل صرامة:

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

Netkod


يستخدم Battle Prime بنية مع خادم استبدادي وتوقعات العميل. يسمح هذا للاعب بتلقي ملاحظات فورية من تصرفاته حتى في حالات الأصوات العالية وفقدان الحزمة ، والمشروع ككل - لتقليل الغش من قبل اللاعبين ، لأن يملي الخادم جميع نتائج المحاكاة داخل المعركة.

تنقسم جميع الشفرات داخل مشروع اللعبة إلى ثلاثة أجزاء:

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

إدخال المستخدم (الإدخال)


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

تنقسم جميع المدخلات من اللاعب إلى نوعين: المستوى المنخفض والعالي المستوى:

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

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

يتم تجميع الإجراءات المرتبطة منطقيا معًا (كائنات من النوع `ActionSet`). يمكن فصل المجموعات إذا لم تكن هناك حاجة إليها في السياق الحالي - على سبيل المثال ، في Battle Prime هناك عدة مجموعات ، من بينها:

  • إجراءات للسيطرة على حركة الشخصية ،
  • إجراءات لإطلاق الأسلحة الآلية ،
  • إجراءات لإطلاق الأسلحة شبه الآلية.

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

وبالمثل ، يتم إنشاء مجموعات من الإجراءات وتكوينها داخل اللعبة داخل أحد الأنظمة:

 static const Map<FastName, ActionSet> action_sets = { { //     ControlModes::CHARACTER_MOVEMENT, ActionSet { { DigitalBinding{ ActionNames::JUMP, { { InputCode::KB_SPACE, DigitalState::just_pressed() } }, nullopt }, DigitalBinding{ ActionNames::MOVE, { { InputCode::KB_W, DigitalState::pressed() } }, ActionValue{ AnalogState{0.0f, 1.0f, 0.0f} } }, //    ... }, { AnalogBinding{ ActionNames::LOOK, InputCode::MOUSE_RELATIVE_POSITION, AnalogStateType::ABSOLUTE, AnalogStateBasis::LOGICAL, {} } //    ... } } }, { //       ControlModes::AUTOMATIC_FIRE, ActionSet { { // FIRE    ,      DigitalBinding{ ActionNames::FIRE, { { InputCode::MOUSE_LBUTTON, DigitalState::pressed() } }, nullopt }, //       ... } } }, { //       ControlModes::SEMI_AUTOMATIC_FIRE, ActionSet { { // FIRE          DigitalBinding{ ActionNames::FIRE, { { InputCode::MOUSE_LBUTTON, DigitalState::just_pressed() } }, nullopt }, //       ... } } } //   ... }; 

يصف Battle Prime حوالي 40 إجراء. يتم استخدام بعضها فقط للتصحيح أو تسجيل مقاطع.

تكرار


النسخ المتماثل هو عملية نقل البيانات من خادم إلى عملاء. يتم نقل جميع البيانات من خلال كائنات في العالم:

  • إنشائها وحذفها ،
  • إنشاء وحذف المكونات على الكائنات ،
  • تغيير خصائص المكون.

تم تكوين النسخ المتماثل باستخدام المكون المناسب. على سبيل المثال ، بطريقة مماثلة ، تقوم اللعبة بإعداد نسخ متماثلة لأسلحة اللاعب:

 auto* replication_component = weapon_entity.add<ReplicationComponent>(); replication_component->enable_replication<WeaponDescriptorComponent>(Privacy::PUBLIC); replication_component->enable_replication<WeaponBaseStatsComponent>(Privacy::PUBLIC); replication_component->enable_replication<WeaponComponent>(Privacy::PRIVATE); replication_component->enable_replication<BallisticsStatsComponent>(Privacy::PRIVATE); // ...    

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

يجب أن يشير كل مكون في وصفه إلى الحقول التي يجب نسخها في هذا المكون. على سبيل المثال ، يتم وضع علامة "Replicable" على جميع الحقول داخل `WeaponComponent`:

 BZ_VIRTUAL_REFLECTION_IMPL(WeaponComponent) { ReflectionRegistrar::begin_class<WeaponComponent>() .ctor_by_pointer() .copy_ctor_by_pointer() .field("owner", &WeaponComponent::owner)[M<Replicable>()] .field("fire_mode", &WeaponComponent::fire_mode)[M<Replicable>()] .field("loaded_ammo", &WeaponComponent::loaded_ammo)[M<Replicable>()] .field("ammo", &WeaponComponent::ammo)[M<Replicable>()] .field("shooting_cooldown_end_ms", &WeaponComponent::shooting_cooldown_end_ms)[M<Replicable>()]; } 

هذه الآلية مريحة جدا للاستخدام. على سبيل المثال ، داخل نظام الخادم ، المسؤول عن "إخراج" الرموز من المعارضين المقتولين (في وضع لعبة خاصة) ، يكفي إضافة وتكوين `ReplicationComponent` على هذا الرمز المميز. يبدو مثل هذا:

 for (const Component* component : added_dead_avatars->components) { Entity kill_token_entity = world->create_entity(); //           // ... //   auto* replication_component = kill_token_entity.add<ReplicationComponent>(); replication_component->enable_replication<TransformComponent>(Privacy::PUBLIC); replication_component->enable_replication<KillTokenComponent>(Privacy::PUBLIC); } 

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

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

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

يحاول نظام النسخ المتماثل تقليل مقدار حركة المرور ، خاصةً عن طريق ضغط البيانات المرسلة (يمكن تحديد كل حقل داخل المكون اختيارياً وفقًا للضغط) ومن خلال إرسال الفرق في القيم بين الإطارين فقط.

توقعات العملاء


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

تنبؤات العميل تعمل وفقًا للقواعد التالية:

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

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

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

 auto* input_packing_sc = world->get_for_write<InputPackingSingleComponent>(); input_packing_sc->packing_schema = { { ActionNames::MOVE, AnalogStatePrecision{ 8, { -1.f, 1.f }, false } }, { ActionNames::LOOK, AnalogStatePrecision{ 16, { -PI, PI }, false } }, { ActionNames::JUMP, nullopt }, // ..    action' }; 

الشرط الحاسم لعمل تنبؤات العميل هو الحاجة إلى أن يكون للمدخل وقت للوصول إلى الخادم بحلول الوقت الذي يتم فيه محاكاة الإطار الذي يرتبط به هذا الإدخال. إذا لم ينجح الإدخال في الوصول إلى الخادم على الإطار المطلوب (يمكن أن يحدث هذا ، على سبيل المثال ، قفزة بينغ حادة) ، سيحاول الخادم استخدام إدخال هذا العميل من الإطار السابق. هذه هي آلية النسخ الاحتياطي التي يمكن أن تساعد في التخلص من التوقعات الخاطئة على العميل في بعض الحالات. على سبيل المثال ، إذا كان العميل يعمل ببساطة في اتجاه واحد ولم يتغير إدخاله لفترة طويلة نسبيًا ، فسيكون استخدام الإدخال للإطار الأخير ناجحًا - سيخمنه الخادم ولن يكون هناك أي تعارض بين العميل والخادم. يتم استخدام مخطط مماثل في Overwatch (تم ذكره في محاضرة في GDC:www.youtube.com/watch؟v=W3aieHjyNvw ).

حاليًا ، يتوقع عميل Battle Prime حالة الكائنات التالية:

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

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

 // `new_local_avatars`       , //      for (Entity avatar : new_local_avatars) { auto* avatar_prediction_component = avatar.add<PredictionComponent>(); avatar_prediction_component->enable_prediction<TransformComponent>(); avatar_prediction_component->enable_prediction<CharacterControllerComponent>(); avatar_prediction_component->enable_prediction<ShooterPrivateComponent>(); avatar_prediction_component->enable_prediction<ShooterPublicComponent>(); // ...      } 

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

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

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

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

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

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

صورة

توقعات خاطئة ورسومات


سوء التقدير - التناقض بين نتائج محاكاة الخادم والعميل. Resimulation هي عملية تصحيح هذا التناقض من قبل العميل.

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

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

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

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

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




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

من أجل دعم تشغيل resimulation ، يجب أن يرث النظام من فئة معينة 'ResimulatableSystem`. في حالة حدوث خطأ غير صحيح ، يقوم العالم "باستعادة" كافة الكائنات إلى آخر حالة خادم معروفة ، ثم يقوم بعدد العدد اللازم من عمليات المحاكاة لإصلاح هذا الخطأ - لن تشارك في هذا النظام سوى الأنظمة Resimulatable.

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

إطلاق نار


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

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

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

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

من الناحية المثالية ، أود أن أتنبأ تمامًا بالقذائف البطيئة في المستقبل - ليس فقط وقت الحياة ، ولكن أيضًا موقعها.

تأخر التعويض


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

النقاط التالية تجعل من الضروري التعويض عن التأخر عند التصوير:

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

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

  • على العميل: مطلق النار على الإطار N1 يطلق رصاصة على رأس العدو الموجود على الإطار N0 (N0 <N1) ؛
  • على الخادم: يقوم مطلق النار الموجود على الإطار N1 بإطلاق النار على رأس العدو ، الموجود أيضًا على الإطار N1 (على الخادم ، كلها في نفس الوقت).

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

للتخلص من هذه المشكلة ، يتم استخدام تعويض التأخير. مخطط عملها هو كما يلي:

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

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

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

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

رمز مع مثال لاستخدام تعويض التأخير من Batle Prime عند تحريك المقذوفات البالستية:

 // `targets_data`    , //   “”    , //    const auto compensated_action = [this](const Vector<LagCompensation::LagCompensationData>& targets_data) { process_projectile(projectile, elapsed_time); }; LagCompensation::invoke( observer, // ,       projectile_component->input_time_ms, // ,      compensated_entities, // ,    compensated_action // ,       ); 

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

مثال على إطلاق النار دون تعويض - تحتاج إلى المضي قدمًا لضرب العدو بثبات:

صورة

ومع التعويض - يمكنك أن تستهدف العدو بشكل مباشر:

صورة

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

صورة

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

استنتاج


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

لإظهار الصورة العامة لـ Battle Prime ، كان علي أن أتطرق إلى عدد كبير من الموضوعات. قد يتم تكريس الكثير منها لفصل المقالات في المستقبل ، والتي سيتم وصفها بمزيد من التفصيل!

تم اختبار اللعبة بالفعل في تركيا والفلبين.

يمكن الاطلاع على مقالاتنا السابقة على الروابط التالية:

  1. habr.com/ru/post/461623
  2. habr.com/ru/post/465343

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


All Articles