Ketika kami menulis kode jaringan penembak PvP seluler: sinkronisasi pemain pada klien

Dalam salah satu artikel sebelumnya , kami meninjau teknologi yang digunakan pada proyek baru kami - penembak serba cepat untuk perangkat seluler. Sekarang saya ingin berbagi bagaimana bagian klien dari kode jaringan dari game masa depan diatur, kesulitan apa yang kami temui dan bagaimana menyelesaikannya.




Secara umum, pendekatan untuk membuat game multi-pemain cepat selama 20 tahun terakhir tidak banyak berubah. Beberapa metode dapat dibedakan dalam arsitektur kode jaringan:

  1. Kesalahan perhitungan keadaan dunia pada server, dan menampilkan hasil pada klien tanpa prediksi untuk pemain lokal dan dengan kemungkinan kehilangan input pemain (input). Omong-omong, pendekatan ini digunakan pada proyek kami yang lain dalam pengembangan - Anda dapat membacanya di sini .
  2. Lockstep
  3. Sinkronisasi keadaan dunia tanpa logika deterministik dengan prediksi untuk pemain lokal.
  4. Sinkronisasi input dengan logika dan prediksi sepenuhnya deterministik untuk pemain lokal.

Kekhasan terletak pada kenyataan bahwa dalam penembak yang paling penting adalah responsif kontrol - pemain menekan tombol (atau menggerakkan joystick) dan ingin segera melihat hasil aksinya. Pertama-tama, karena keadaan dunia dalam permainan seperti itu berubah sangat cepat dan perlu untuk segera menanggapi situasi.

Sebagai akibatnya, pendekatan tanpa mekanisme prediksi tindakan pemain lokal (prediksi) tidak cocok untuk proyek, dan kami memilih metode dengan menyinkronkan keadaan dunia, tanpa logika deterministik.

Keuntungan dari pendekatan: lebih sedikit kompleksitas dalam implementasi dibandingkan dengan metode sinkronisasi ketika bertukar input.
Minus: peningkatan lalu lintas saat mengirim seluruh negara di dunia ke klien. Kami harus menerapkan beberapa teknik pengoptimalan lalu lintas yang berbeda untuk membuat game bekerja secara stabil di jaringan seluler.

Di jantung arsitektur gameplay kami memiliki ECS, yang telah kami bicarakan . Arsitektur ini memungkinkan Anda menyimpan data tentang dunia game dengan mudah, membuat serial, menyalin, dan mentransfernya melalui jaringan. Dan juga untuk mengeksekusi kode yang sama baik pada klien dan di server.

Simulasi dunia game berlangsung pada frekuensi tetap 30 ticks per detik. Ini memungkinkan Anda untuk mengurangi kelambatan pada input pemain dan hampir tidak menggunakan interpolasi untuk secara visual menampilkan kondisi dunia. Tetapi ada satu kelemahan signifikan yang harus dipertimbangkan ketika mengembangkan sistem seperti itu: agar sistem prediksi pemain lokal berfungsi dengan benar, klien harus mensimulasikan dunia dengan frekuensi yang sama dengan server. Dan kami menghabiskan banyak waktu untuk mengoptimalkan simulasi cukup untuk perangkat target.

Mekanisme prediksi aksi pemain lokal (prediksi)


Mekanisme prediksi klien diimplementasikan berdasarkan ECS karena eksekusi sistem yang sama pada klien dan server. Namun, tidak semua sistem dijalankan pada klien, tetapi hanya yang bertanggung jawab untuk pemain lokal dan tidak memerlukan data yang relevan tentang pemain lain.

Contoh daftar sistem yang berjalan di klien dan server:



Saat ini, kami memiliki sekitar 30 sistem yang berjalan di klien yang memberikan prediksi pemain dan sekitar 80 sistem yang berjalan di server. Tetapi kami tidak memprediksi hal-hal seperti menangani kerusakan, menggunakan kemampuan, atau menyembuhkan sekutu. Ada dua masalah dalam mekanika ini:

  1. Klien tidak tahu apa-apa tentang memasukkan pemain lain dan memprediksi hal-hal seperti kerusakan atau penyembuhan hampir selalu berbeda dari data di server.
  2. Membuat entitas baru secara lokal (tembakan, cangkang, kemampuan unik) yang dihasilkan oleh satu pemain membawa masalah pencocokan dengan entitas yang dibuat di server.

Untuk mekanik seperti itu, lag bersembunyi dari pemain dengan cara lain.

Contoh: kami segera menggambar efek dari tembakan, dan kami memperbarui kehidupan musuh hanya setelah kami menerima konfirmasi mengenai serangan dari server.

Skema umum kode jaringan dalam proyek




Klien dan server menyinkronkan waktu dengan nomor centang. Karena kenyataan bahwa pengiriman data melalui jaringan membutuhkan waktu, klien selalu lebih unggul dari server sebesar setengah RTT + ukuran buffer input pada server. Diagram di atas menunjukkan bahwa klien mengirim input untuk centang 20 (a). Pada saat yang sama, centang 15 (b) diproses di server. Pada saat input klien mencapai server, centang 20 akan diproses di server.

Seluruh proses terdiri dari langkah-langkah berikut: klien mengirim input pemain ke server (a) → input ini diproses di server setelah ukuran buffer input HRTT + (b) → server mengirim negara dunia yang dihasilkan kepada klien → klien menerapkan negara dunia yang dikonfirmasi dengan waktu server RTT + ukuran buffer input + ukuran buffer interpolasi status permainan (d).

Setelah klien menerima keadaan dunia baru yang dikonfirmasi dari server (d), ia perlu menyelesaikan proses rekonsiliasi. Faktanya adalah bahwa klien melakukan prediksi dunia hanya berdasarkan pada input pemain lokal. Input dari pemain lain tidak dikenalnya. Dan ketika menghitung keadaan dunia di server, pemain mungkin berada dalam keadaan yang berbeda, berbeda dari apa yang diprediksi klien. Ini bisa terjadi ketika seorang pemain tertegun atau terbunuh.

Proses persetujuan terdiri dari dua bagian:

  1. Perbandingan kondisi prediksi dunia untuk tick N yang diterima dari server. Hanya data yang terkait dengan pemain lokal yang terlibat dalam perbandingan. Data dunia lainnya selalu diambil dari status server dan tidak berpartisipasi dalam koordinasi.
  2. Selama perbandingan, dua kasus dapat terjadi:

- jika keadaan dunia yang diprediksi bertepatan dengan yang dipastikan dari server, maka klien, menggunakan data yang diprediksi untuk pemain lokal dan data baru untuk seluruh dunia, terus mensimulasikan dunia dalam mode normal;
- jika kondisi prediksi tidak cocok, maka klien menggunakan seluruh kondisi server dunia dan sejarah input dari klien dan menceritakan kondisi prediksi baru dunia pemain.

Dalam kode, tampilannya seperti ini:
GameState Reconcile(int currentTick, ServerGameStateData serverStateData, GameState currentState, uint playerID) { var serverState = serverStateData.GameState; var serverTick = serverState.Time; var predictedState = _localStateHistory.Get(serverTick); //if predicted state matches server last state use server predicted state with predicted player if (_gameStateComparer.IsSame(predictedState, serverState, playerID)) { _tempState.Copy(serverState); _gameStateCopier.CopyPlayerEntities(currentState, _tempState, playerID); return _localStateHistory.Put(_tempState); // replace predicted state with correct server state } //if predicted state doesn't match server state, reapply local inputs to server state var last = _localStateHistory.Put(serverState); // replace wrong predicted state with correct server state for (var i = serverTick; i < currentTick; i++) { last = _prediction.Predict(last); // resimulate all wrong states } return last; } 


Perbandingan dua negara dunia hanya terjadi untuk data yang terkait dengan pemain lokal dan berpartisipasi dalam sistem prediksi. Data diambil berdasarkan ID pemain.

Metode perbandingan:
 public bool IsSame(GameState s1, GameState s2, uint avatarId) { if (s1 == null && s2 != null || s1 != null && s2 == null) return false; if (s1 == null && s2 == null) return false; var entity1 = s1.WorldState[avatarId]; var entity2 = s2.WorldState[avatarId]; if (entity1 == null && entity2 == null) return false; if (entity1 == null || entity2 == null) return false; if (s1.Time != s2.Time) return false; if (s1.WorldState.Transform[avatarId] != s2.WorldState.Transform[avatarId]) return false; foreach (var s1Weapon in s1.WorldState.Weapon) { if (s1Weapon.Value.Owner.Id != avatarId) continue; var s2Weapon = s2.WorldState.Weapon[s1Weapon.Key]; if (s1Weapon.Value != s2Weapon) return false; var s1Ammo = s1.WorldState.WeaponAmmo[s1Weapon.Key]; var s2Ammo = s2.WorldState.WeaponAmmo[s1Weapon.Key]; if (s1Ammo != s2Ammo) return false; var s1Reload = s1.WorldState.WeaponReloading[s1Weapon.Key]; var s2Reload = s2.WorldState.WeaponReloading[s1Weapon.Key]; if (s1Reload != s2Reload) return false; } if (entity1.Aiming != entity2.Aiming) return false; if (entity1.ChangeWeapon != entity2.ChangeWeapon) return false; return true; } 


Operator pembanding untuk komponen tertentu dihasilkan bersama dengan seluruh struktur EC, yang ditulis secara khusus oleh pembuat kode. Sebagai contoh, saya akan memberikan kode yang dihasilkan dari operator perbandingan komponen Transform:

Kode
 public static bool operator ==(Transform a, Transform b) { if ((object)a == null && (object)b == null) return true; if ((object)a == null && (object)b != null) return false; if ((object)a != null && (object)b == null) return false; if (Math.Abs(a.Angle - b.Angle) > 0.01f) return false; if (Math.Abs(a.Position.x - b.Position.x) > 0.01f || Math.Abs(a.Position.y - b.Position.y) > 0.01f) return false; return true; } 


Perlu dicatat bahwa nilai Float kami dibandingkan dengan kesalahan yang agak tinggi. Ini dilakukan untuk mengurangi jumlah desync antara klien dan server. Untuk pemain, kesalahan seperti itu tidak akan terlihat, tetapi ini secara signifikan menghemat sumber daya komputasi sistem.

Kompleksitas mekanisme koordinasi adalah bahwa dalam hal terjadi kesalahan sinkronisasi klien dan kondisi server (misprediksi), perlu untuk berulang kali mensimulasikan semua kondisi klien yang diprediksi tentang yang tidak ada konfirmasi dari server, hingga centang saat ini dalam satu frame. Bergantung pada ping pemain, ini bisa dari 5 hingga 20 kutu simulasi. Kami harus mengoptimalkan kode simulasi secara signifikan agar sesuai dengan kerangka waktu: 30 fps.

Untuk menyelesaikan proses persetujuan, dua jenis data harus disimpan pada klien:

  1. Sejarah negara pemain yang diprediksi.
  2. Dan sejarah input.

Untuk tujuan ini, kami menggunakan buffer lingkaran. Ukuran buffer adalah 32 ticks. Bahwa pada frekuensi 30 HZ memberi sekitar 1 detik waktu nyata. Klien dapat terus bekerja dengan aman pada mekanisme prediksi, tanpa menerima data baru dari server, hingga mengisi buffer ini. Jika perbedaan antara waktu klien dan server mulai lebih dari satu detik, klien dipaksa untuk memutuskan sambungan dengan upaya untuk menghubungkan kembali. Kami memiliki ukuran buffer seperti itu karena biaya proses koordinasi jika terjadi ketidaksesuaian antara negara-negara di dunia. Tetapi jika perbedaan antara klien dan server lebih dari satu detik, lebih murah untuk melakukan koneksi ulang lengkap ke server.

Pengurangan waktu jeda


Diagram di atas menunjukkan bahwa dalam permainan ada dua buffer dalam skema transfer data:

  • masukan buffer di server;
  • penyangga negara-negara dunia pada klien.

Tujuan dari buffer ini adalah sama - untuk mengkompensasi lompatan jaringan (jitter). Faktanya adalah bahwa pengiriman paket melalui jaringan tidak merata. Dan karena mesin jaringan beroperasi pada frekuensi tetap 30 HZ, data harus dipasok ke mesin pada frekuensi yang sama. Kami tidak memiliki kesempatan untuk "menunggu" beberapa ms sampai paket berikutnya mencapai penerima. Kami menggunakan buffer untuk memasukkan data dan negara-negara dunia agar memiliki batas waktu untuk kompensasi jitter. Kami juga menggunakan buffer gamestate untuk interpolasi jika salah satu paket hilang.

Di awal permainan, klien memulai sinkronisasi dengan server hanya setelah menerima beberapa status dunia dari server dan buffer gamestate penuh. Biasanya, ukuran buffer ini adalah 3 ticks (100 ms).

Pada saat yang sama, ketika klien menyinkronkan dengan server, itu "berjalan" di depan waktu server dengan nilai buffer input pada server. Yaitu klien itu sendiri mengontrol seberapa jauh dari server itu. Ukuran awal buffer input juga sama dengan 3 ticks (100 ms).

Awalnya, kami menerapkan ukuran buffer ini sebagai konstanta. Yaitu terlepas dari apakah jitter benar-benar ada di jaringan atau tidak, ada penundaan tetap sebesar 200 ms (ukuran buffer input + ukuran buffer status permainan) untuk memperbarui data. Jika kita menambahkan ini perkiraan ping rata-rata pada perangkat seluler sekitar 200 ms, maka penundaan nyata antara menggunakan input pada klien dan mengonfirmasi aplikasi dari server adalah 400 ms!

Ini tidak cocok untuk kita.

Faktanya adalah bahwa beberapa sistem hanya berjalan di server - seperti, misalnya, menghitung HP pemain. Dengan penundaan ini, pemain menembak dan hanya setelah 400 ms melihat bagaimana ia membunuh lawan. Jika ini terjadi dalam gerakan, maka biasanya pemain berhasil berlari di belakang dinding atau ke penutup dan sudah sekarat di sana. Playtests dalam tim menunjukkan bahwa penundaan seperti itu benar-benar menghancurkan seluruh gameplay.

Solusi untuk masalah ini adalah penerapan ukuran dinamis buffer input dan gamestate:
  • untuk buffer gamestate, klien selalu tahu konten buffer saat ini. Pada saat menghitung centang selanjutnya, klien memeriksa berapa banyak status yang sudah ada dalam buffer;
  • untuk buffer input - server, selain status permainan, mulai mengirim nilai kepada klien untuk mengisi input buffer saat ini untuk klien tertentu. Klien pada gilirannya menganalisis kedua nilai ini.

Algoritma pengubah ukuran bufferest gamestate kira-kira sebagai berikut:

  1. Klien mempertimbangkan nilai rata-rata ukuran buffer selama periode waktu dan varians.
  2. Jika varians berada dalam batas normal (mis., Untuk periode waktu tertentu tidak ada lompatan besar dalam mengisi dan membaca dari buffer), klien memeriksa nilai ukuran buffer rata-rata untuk periode waktu ini.
  3. Jika pengisian buffer rata-rata lebih besar dari kondisi batas atas (yaitu, buffer akan diisi lebih dari yang diperlukan), klien "mengurangi" ukuran buffer dengan melakukan centang simulasi tambahan.
  4. Jika rata-rata pengisian buffer kurang dari kondisi batas bawah (yaitu, buffer tidak punya waktu untuk mengisi sebelum klien mulai membaca dari itu) - dalam hal ini, klien "meningkatkan" ukuran buffer dengan melewatkan satu centang simulasi.
  5. Dalam kasus ketika varians di atas normal, kami tidak dapat mengandalkan data ini, karena lonjakan jaringan untuk jangka waktu tertentu terlalu besar. Kemudian klien membuang semua data saat ini dan mulai mengumpulkan statistik lagi.

Kompensasi keterlambatan server


Karena kenyataan bahwa klien menerima pembaruan dunia dari server dengan penundaan (lag), pemain melihat dunia sedikit berbeda dari yang ada di server. Pemain melihat dirinya di masa kini, dan seluruh dunia - di masa lalu. Di server, seluruh dunia ada dalam satu waktu.


Karena itu, situasinya adalah bahwa pemain menembak secara lokal pada target yang terletak di server di tempat lain.

Untuk mengkompensasi kelambatan, kami menggunakan waktu mundur di server. Algoritma operasi kira-kira sebagai berikut:

  1. Klien dengan setiap input juga mengirim ke server waktu centang di mana ia melihat seluruh dunia.
  2. Server memvalidasi waktu ini: adalah perbedaan antara waktu saat ini dan waktu yang terlihat dari dunia klien dalam interval kepercayaan.
  3. Jika waktu berlaku, server meninggalkan pemain dalam waktu saat ini, dan seluruh dunia kembali ke keadaan bahwa pemain melihat dan menghitung hasil tembakan.
  4. Jika seorang pemain memukul, maka kerusakan dilakukan pada waktu server saat ini.

Waktu rewinding pada server berfungsi sebagai berikut: sejarah dunia (dalam ECS) dan sejarah fisika (didukung oleh mesin Fisika Volatile ) disimpan di utara. Pada saat tembakan dihitung, data pemain diambil dari keadaan dunia saat ini, dan sisa pemain dari sejarah.

Kode untuk sistem validasi gambar terlihat seperti ini:
 public void Execute(GameState gs) { foreach (var shotPair in gs.WorldState.Shot) { var shot = shotPair.Value; var shooter = gs.WorldState[shotPair.Key]; var shooterTransform = shooter.Transform; var weaponStats = gs.WorldState.WeaponStats[shot.WeaponId]; // DeltaTime shouldn't exceed physics history size var shootDeltaTime = (int) (gs.Time - shot.ShotPlayerWorldTime); if (shootDeltaTime > PhysicsWorld.HistoryLength) { continue; } // Get the world at the time of shooting. var oldState = _immutableHistory.Get(shot.ShotPlayerWorldTime); var potentialTarget = oldState.WorldState[shot.Target.Id]; var hitTargetId = _singleShotValidator.ValidateTargetAvailabilityInLine(oldState, potentialTarget, shooter, shootDeltaTime, weaponStats.ShotDistance, shooter.Transform.Angle.GetDirection()); if (hitTargetId != 0) { gs.WorldState.CreateEntity().AddDamage(gs.WorldState[hitTargetId], shooter, weaponStats.ShotDamage); } } } 


Salah satu kelemahan signifikan dalam pendekatan ini adalah bahwa kami mempercayai klien dalam data tentang waktu centang yang dia lihat. Berpotensi, pemain dapat memperoleh keuntungan dengan meningkatkan ping secara artifisial. Karena semakin banyak ping yang dimiliki pemain, semakin jauh dia menembak di masa lalu.

Beberapa masalah kami temui


Selama implementasi mesin jaringan ini, kami menemui banyak masalah, beberapa di antaranya layak untuk artikel terpisah, tetapi di sini saya hanya akan menyentuh beberapa di antaranya.

Simulasi seluruh dunia dalam sistem prediksi dan penyalinan


Awalnya, semua sistem di ECS kami hanya memiliki satu metode: void Execute (GameState gs). Dalam metode ini, komponen yang terkait dengan semua pemain biasanya diproses.

Contoh sistem gerak dalam implementasi awal:
 public sealed class MovementSystem : ISystem { public void Execute(GameState gs) { foreach (var movementPair in gs.WorldState.Movement) { var transform = gs.WorldState.Transform[movementPair.Key]; transform.Position += movementPair.Value.Velocity * GameState.TickDuration; } } } 


Namun dalam sistem prediksi pemain lokal, kami hanya perlu memproses komponen yang terkait dengan pemain tertentu. Awalnya, kami menerapkan ini menggunakan salinan.

Proses prediksi adalah sebagai berikut:

  1. Salinan keadaan permainan telah dibuat.
  2. Salinan diberikan ke input ECS.
  3. Ada simulasi seluruh dunia di ECS.
  4. Semua data yang terkait dengan pemain lokal disalin dari gamestate yang baru diterima.

Metode prediksi terlihat seperti ini:
 void PredictNewState(GameState state) { var newState = _stateHistory.Get(state.Tick+1); var input = _inputHistory.Get(state.Tick); newState.Copy(state); _tempGameState.Copy(state); _ecsExecutor.Execute(_tempGameState, input); _playerEntitiesCopier.Copy(_tempGameState, newState); } 


Ada dua masalah dalam implementasi ini:

  1. Karena kami menggunakan kelas, bukan struktur - menyalin adalah operasi yang cukup mahal bagi kami (sekitar 0,1-0,15 ms pada iPhone 5S).
  2. Simulasi seluruh dunia juga membutuhkan banyak waktu (sekitar 1,5-2 ms pada iPhone 5S).

Jika kita memperhitungkan bahwa selama proses koordinasi perlu untuk menghitung ulang dari 5 hingga 15 negara di dunia dalam satu kerangka, maka dengan implementasi seperti itu semuanya berjalan sangat lambat.

Solusinya cukup sederhana: belajar mensimulasikan dunia dalam bagian-bagian, yaitu hanya mensimulasikan pemain tertentu. Kami menulis ulang semua sistem sehingga Anda dapat mentransfer ID pemain dan hanya mensimulasikannya.

Contoh sistem pergerakan setelah perubahan:
 public sealed class MovementSystem : ISystem { public void Execute(GameState gs) { foreach (var movementPair in gs.WorldState.Movement) { Move(gs.WorldState.Transform[movementPair.Key], movementPair.Value); } } public void ExecutePlayer(GameState gs, uint playerId) { var movement = gs.WorldState.Movement[playerId]; if(movement != null) { Move(gs.WorldState.Transform[playerId], movement); } } private void Move(Transform transform, Movement movement) { transform.Position += movement.Velocity * GameState.TickDuration; } } 


Setelah perubahan, kami dapat menyingkirkan salinan yang tidak perlu dalam sistem prediksi dan mengurangi beban pada sistem yang cocok.

Kode:
 void PredictNewState(GameState state, uint playerId) { var newState = _stateHistory.Get(state.Tick+1); var input = _inputHistory.Get(state.Tick); newState.Copy(state); _ecsExecutor.Execute(newState, input, playerId); } 


Membuat dan menghapus entitas dalam sistem prediksi


Dalam sistem kami, pencocokan entitas pada server dan klien terjadi oleh pengenal integer (id). Untuk semua entitas, kami menggunakan penomoran ujung ke ujung, setiap entitas baru memiliki nilai id = oldID + 1.

Pendekatan ini sangat mudah diterapkan, tetapi memiliki satu kelemahan signifikan: urutan pembuatan entitas baru pada klien dan server mungkin berbeda, dan sebagai hasilnya, pengidentifikasi entitas akan berbeda.

Masalah ini memanifestasikan dirinya ketika kami menerapkan sistem untuk memprediksi tembakan pemain. Setiap tembakan dengan kami adalah entitas yang terpisah dengan komponen tembakan. Untuk setiap klien, id dari entitas shot dalam sistem prediksi berurutan. Tetapi jika pada saat yang sama tembakan pemain lain, maka di server id semua tembakan berbeda dari klien.

Tembakan di server dibuat dalam urutan yang berbeda:



Untuk bidikan, kami menghindari batasan ini, berdasarkan pada fitur gameplay dari game. Bidikan adalah entitas yang hidup cepat yang dihancurkan dalam sistem, sepersekian detik setelah penciptaan. Pada klien, kami menyoroti rentang ID yang terpisah yang tidak berpotongan dengan ID server dan tidak lagi mengambil gambar akun dalam sistem koordinasi. Yaitu Tembakan pemain lokal selalu ditarik dalam permainan hanya sesuai dengan sistem prediksi dan tidak memperhitungkan data akun dari server.

Dengan pendekatan ini, pemain tidak melihat artefak di layar (menghapus, menciptakan kembali, mengembalikan gambar), dan perbedaan dengan server bersifat minor dan tidak mempengaruhi gameplay secara keseluruhan.

Metode ini memungkinkan untuk memecahkan masalah dengan tembakan, tetapi tidak seluruh masalah menciptakan entitas pada klien secara keseluruhan. Kami masih bekerja pada metode yang mungkin untuk menyelesaikan perbandingan objek yang dibuat pada klien dan server.

Perlu juga dicatat bahwa masalah ini hanya menyangkut penciptaan entitas baru (dengan ID baru). Menambah dan menghapus komponen pada entitas yang sudah dibuat dilakukan tanpa masalah: komponen tidak memiliki pengidentifikasi dan masing-masing entitas hanya dapat memiliki satu komponen dari tipe tertentu. Oleh karena itu, kami biasanya membuat entitas di server, dan dalam sistem prediksi kami hanya menambah / menghapus komponen.

Sebagai kesimpulan, saya ingin mengatakan bahwa tugas menerapkan multi-pemain bukanlah yang termudah dan tercepat, tetapi ada banyak informasi tentang cara melakukan ini.

Apa yang harus dibaca


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


All Articles