كيف صنعنا آليات الحساب الباليستي لمطلق النار المحمول باستخدام خوارزمية تعويض تأخير الشبكة

كيف صنعنا آليات الحساب الباليستي لمطلق النار المحمول باستخدام خوارزمية تعويض تأخير الشبكة

مرحبًا، أنا نيكيتا بريزاك، مطور خوادم من Pixonic. أود اليوم أن أتحدث عن التعويض عن التأخر في اللعب الجماعي على الأجهزة المحمولة.

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

نحن نستخدمها أيضًا في لعبة Dino Squad متعددة اللاعبين على الهاتف المحمول.

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

بضع كلمات عن قشرتنا وتقنياتنا.

Dino Squad هي لعبة إطلاق نار PvP عبر الشبكة المحمولة. يتحكم اللاعبون في الديناصورات المجهزة بمجموعة متنوعة من الأسلحة ويقاتلون بعضهم البعض في فرق 6 ضد 6.

يعتمد كل من العميل والخادم على الوحدة. تعتبر الهندسة المعمارية كلاسيكية تمامًا بالنسبة إلى الرماة: الخادم استبدادي، ويعمل توقع العميل على العملاء. تتم كتابة محاكاة اللعبة باستخدام ECS داخليًا ويتم استخدامها على كل من الخادم والعميل.

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

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

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

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

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

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

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

كان الحل ظاهريا، وقد استخدمنا جميع عناصره لحل مشاكل أخرى:

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

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

public class TimeMachine : ITimeMachine
{
     //История игровых состояний
     private readonly IGameStateHistory _history;

     //Текущее игровое состояние на сервере
     private readonly ExecutableSystem[] _systems;

     //Набор систем, расставляющих коллайдеры в физическом мире 
     //по данным из игрового состояния
     private readonly GameState _presentState;

     public TimeMachine(IGameStateHistory history, GameState presentState, ExecutableSystem[] timeInitSystems)
     {
         _history = history; 
         _presentState = presentState;
         _systems = timeInitSystems;  
     }

     public GameState TravelToTime(int tick)
     {
         var pastState = tick == _presentState.Time ? _presentState : _history.Get(tick);
         foreach (var system in _systems)
         {
             system.Execute(pastState);
         }
         return pastState;
     }
}

كل ما تبقى هو معرفة كيفية استخدام هذه الآلة للتعويض بسهولة عن التسديدات والقدرات.

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

ولكن هناك عدد قليل جدًا من هذه الآليات في Dino Squad! تصنع معظم الأسلحة الموجودة في اللعبة مقذوفات - رصاصات طويلة العمر تطير لعدة علامات محاكاة (في بعض الحالات، عشرات من علامات التجزئة). ماذا تفعل معهم، في أي وقت يجب أن يطيروا؟

В المادة القديمة فيما يتعلق بمكدس شبكة Half-Life، طرح الرجال من Valve نفس السؤال، وكانت إجابتهم كما يلي: تعويض تأخر القذيفة يمثل مشكلة، ومن الأفضل تجنبه.

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

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

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

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

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

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

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

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

في هذه المرحلة لدينا نظام عمل بشكل عام. الكود الخاص به في شكل مبسط إلى حد ما:

public sealed class LagCompensationSystemGroup : ExecutableSystem
{
     //Машина времени
     private readonly ITimeMachine _timeMachine;

     //Набор систем лагкомпенсации
     private readonly LagCompensationSystem[] _systems;
     
     //Наша реализация кластеризатора
     private readonly TimeTravelMap _travelMap = new TimeTravelMap();

    public LagCompensationSystemGroup(ITimeMachine timeMachine, 
        LagCompensationSystem[] lagCompensationSystems)
     {
         _timeMachine = timeMachine;
         _systems = lagCompensationSystems;
     }

     public override void Execute(GameState gs)
     {
         //На вход кластеризатор принимает текущее игровое состояние,
         //а на выход выдает набор «корзин». В каждой корзине лежат энтити,
         //которым для лагкомпенсации нужно одно и то же время из истории.
         var buckets = _travelMap.RefillBuckets(gs);

         for (int bucketIndex = 0; bucketIndex < buckets.Count; bucketIndex++)
         {
             ProcessBucket(gs, buckets[bucketIndex]);
         }

         //В конце лагкомпенсации мы восстанавливаем физический мир 
         //в исходное состояние
         _timeMachine.TravelToTime(gs.Time);
     }

     private void ProcessBucket(GameState presentState, TimeTravelMap.Bucket bucket)
     {
         //Откатываем время один раз для каждой корзины
         var pastState = _timeMachine.TravelToTime(bucket.Time);

         foreach (var system in _systems)
         {
               system.PastState = pastState;
               system.PresentState = presentState;

               foreach (var entity in bucket)
               {
                   system.Execute(entity);
               }
          }
     }
}

كل ما تبقى هو تكوين التفاصيل:

1. فهم مقدار تحديد الحد الأقصى لمسافة الحركة في الوقت المناسب.

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

2. تحديد الأشياء التي يمكن نقلها في الوقت المناسب والتي لا يمكن نقلها.

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

3. قرر ما إذا كان من الضروري التعويض عن قدرات الديناصورات: العض، الضرب بالذيل، وما إلى ذلك. قررنا ما هو مطلوب وقمنا بمعالجتها وفقًا لنفس قواعد الرصاص.

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

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

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

ثالثًا، نقوم بحساب مواضع سلاح الديناصورات أو نقطة تطبيق القدرة باستخدام البيانات من ECS حتى قبل بدء تعويض التأخر.

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

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

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

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

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

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

المصدر: www.habr.com

إضافة تعليق