چگونه مکانیک محاسبات بالستیک را برای یک تیرانداز متحرک با الگوریتم جبران تاخیر شبکه افزایش دادیم

چگونه مکانیک محاسبات بالستیک را برای یک تیرانداز متحرک با الگوریتم جبران تاخیر شبکه افزایش دادیم

سلام، من نیکیتا بریژاک هستم، یک توسعه دهنده سرور از Pixonic. امروز می خواهم در مورد جبران تاخیر در چند نفره موبایل صحبت کنم.

مقالات زیادی در مورد جبران تاخیر سرور نوشته شده است، از جمله به زبان روسی. این تعجب آور نیست، زیرا این فناوری از اواخر دهه 90 به طور فعال در ایجاد FPS چند نفره استفاده شده است. به عنوان مثال، می توانید مد QuakeWorld را به یاد بیاورید که یکی از اولین مواردی بود که از آن استفاده کرد.

ما همچنین از آن در تیرانداز چند نفره موبایل خود Dino Squad استفاده می کنیم.

در این مقاله، هدف من تکرار آنچه قبلاً هزار بار نوشته شده است نیست، بلکه این است که بگویم با در نظر گرفتن پشته فناوری و ویژگی‌های اصلی گیم‌پلی، چگونه جبران تاخیر را در بازی خود پیاده‌سازی کردیم.

چند کلمه در مورد قشر و تکنولوژی ما.

Dino Squad یک تیرانداز شبکه تلفن همراه PvP است. بازیکنان دایناسورهای مجهز به انواع سلاح ها را کنترل می کنند و در تیم های 6 در 6 با یکدیگر مبارزه می کنند.

کلاینت و سرور هر دو بر اساس Unity هستند. معماری برای تیراندازان کاملاً کلاسیک است: سرور مستبد است و پیش‌بینی مشتری روی کلاینت‌ها کار می‌کند. شبیه سازی بازی با استفاده از ECS داخلی نوشته شده است و هم روی سرور و هم روی کلاینت استفاده می شود.

اگر این اولین بار است که در مورد جبران تاخیر می شنوید، در اینجا یک گشت و گذار مختصر در مورد این موضوع وجود دارد.

در بازی های چند نفره FPS، مسابقه معمولاً روی یک سرور راه دور شبیه سازی می شود. بازیکنان ورودی خود (اطلاعات مربوط به کلیدهای فشرده شده) را به سرور ارسال می کنند و در پاسخ سرور با در نظر گرفتن داده های دریافتی وضعیت بازی به روز شده را برای آنها ارسال می کند. با این طرح تعامل، تأخیر بین فشار دادن کلید جلو و لحظه ای که شخصیت بازیکن روی صفحه حرکت می کند همیشه بیشتر از پینگ خواهد بود.

در حالی که در شبکه‌های محلی این تأخیر (که معمولاً تأخیر ورودی نامیده می‌شود) ممکن است غیرقابل توجه باشد، هنگام بازی از طریق اینترنت، هنگام کنترل یک شخصیت، احساس "لغزش روی یخ" ایجاد می‌کند. این مشکل برای شبکه های تلفن همراه دوچندان است، جایی که موردی که پینگ بازیکن 200 میلی ثانیه است هنوز یک اتصال عالی در نظر گرفته می شود. اغلب پینگ می تواند 350، 500 یا 1000 میلی ثانیه باشد. سپس بازی یک شوتر سریع با تاخیر ورودی تقریبا غیرممکن می شود.

راه حل این مشکل، پیش بینی شبیه سازی سمت مشتری است. در اینجا خود کلاینت ورودی را به شخصیت بازیکن اعمال می کند، بدون اینکه منتظر پاسخی از طرف سرور باشد. و هنگامی که پاسخ دریافت می شود، به سادگی نتایج را مقایسه می کند و موقعیت های مخالفان را به روز می کند. تاخیر بین فشار دادن یک کلید و نمایش نتیجه بر روی صفحه در این حالت حداقل است.

درک تفاوت های ظریف در اینجا مهم است: مشتری همیشه خود را مطابق آخرین ورودی خود ترسیم می کند و دشمنان - با تاخیر شبکه، مطابق با وضعیت قبلی از داده های سرور. یعنی وقتی بازیکن به دشمن شلیک می کند، او را در گذشته نسبت به خودش می بیند. اطلاعات بیشتر در مورد پیش بینی مشتری قبلا نوشتیم.

بنابراین، پیش‌بینی مشتری یک مشکل را حل می‌کند، اما مشکل دیگری ایجاد می‌کند: اگر بازیکنی در نقطه‌ای که دشمن در گذشته بوده، روی سرور هنگام شلیک در همان نقطه شلیک کند، ممکن است دشمن دیگر در آن مکان نباشد. جبران تاخیر سرور برای حل این مشکل تلاش می کند. هنگامی که یک سلاح شلیک می شود، سرور حالت بازی را که بازیکن در زمان شلیک به صورت محلی دیده بود، باز می گرداند و بررسی می کند که آیا واقعاً می توانسته به دشمن ضربه بزند یا خیر. اگر پاسخ "بله" باشد، ضربه شمرده می شود، حتی اگر دشمن دیگر در آن نقطه روی سرور نباشد.

با داشتن این دانش، ما شروع به پیاده سازی جبران تاخیر سرور در Dino Squad کردیم. اول از همه، ما باید درک می کردیم که چگونه می توان آنچه را که مشتری دید را در سرور بازیابی کرد؟ و دقیقا چه چیزی باید بازیابی شود؟ در بازی ما، ضربات سلاح‌ها و توانایی‌ها از طریق پرتوها و پوشش‌ها محاسبه می‌شوند - یعنی از طریق تعامل با برخورد دهنده‌های فیزیکی دشمن. بر این اساس، ما نیاز به بازتولید موقعیت این برخورددهنده ها، که بازیکن به صورت محلی "دید" در سرور بودیم. در آن زمان ما از نسخه یونیتی 2018.x استفاده می کردیم. API فیزیک در آنجا ثابت است، دنیای فیزیکی در یک نسخه وجود دارد. هیچ راهی برای ذخیره وضعیت آن و سپس بازیابی آن از جعبه وجود ندارد. خوب چه کار کنیم؟

راه حل در سطح بود؛ همه عناصر آن قبلاً توسط ما برای حل مشکلات دیگر استفاده شده بود:

  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 (و شاید کمی قبل از آن)، یونیتی پشتیبانی کامل از صحنه های فیزیکی مستقل را اضافه کرد. ما آنها را تقریباً بلافاصله پس از به‌روزرسانی روی سرور پیاده‌سازی کردیم، زیرا می‌خواستیم به سرعت از دنیای فیزیکی مشترک در همه اتاق‌ها خلاص شویم.

ما به هر اتاق بازی صحنه فیزیکی خاص خود را دادیم و بنابراین نیازی به "پاک کردن" صحنه از داده های اتاق همسایه قبل از محاسبه شبیه سازی را حذف کردیم. اولا، افزایش قابل توجهی در بهره وری ایجاد کرد. ثانیاً، خلاص شدن از شر یک کلاس کامل از اشکالات که در صورت خطای برنامه نویس در کد پاکسازی صحنه هنگام افزودن عناصر جدید بازی ایجاد می شد، امکان پذیر کرد. اشکال زدایی چنین خطاهایی دشوار بود، و اغلب به وضعیت اشیاء فیزیکی در صحنه یک اتاق منجر می شد که به اتاق دیگر "جریان می یابند".

علاوه بر این، ما تحقیقاتی در مورد اینکه آیا می توان از صحنه های فیزیکی برای ذخیره تاریخ دنیای فیزیکی استفاده کرد یا خیر، انجام دادیم. یعنی به صورت مشروط به هر اتاق نه یک صحنه، بلکه 30 صحنه اختصاص دهید و از آنها یک بافر چرخه ای بسازید که داستان را در آن ذخیره کنید. به طور کلی، این گزینه کار می کند، اما ما آن را اجرا نکردیم: هیچ افزایش دیوانه کننده ای در بهره وری نشان نداد، اما به تغییرات نسبتاً خطرناکی نیاز داشت. پیش بینی رفتار سرور در هنگام کار طولانی مدت با این همه صحنه دشوار بود. بنابراین از این قاعده پیروی کردیم:اگر خراب نیست ، آن را اصلاح نکنید'.

منبع: www.habr.com

اضافه کردن نظر