Solusi arsitektur untuk gim seluler. Bagian 1: Model

Epigraf:
- Bagaimana saya akan mengevaluasi jika Anda tidak tahu harus berbuat apa?
- Ya, akan ada layar dan tombol.
- Dima, Anda sekarang telah menggambarkan seluruh hidup saya dalam tiga kata!
(c) Dialog nyata pada rapat umum di perusahaan game



Rangkaian kebutuhan dan solusi yang memenuhi mereka, yang akan saya bahas dalam artikel ini, dibentuk selama partisipasi saya di sekitar selusin proyek besar, pertama di Flash dan kemudian di Unity. Proyek terbesar memiliki lebih dari 200.000 DAU dan menambah celengan saya dengan tantangan orisinal baru. Di sisi lain, relevansi dan perlunya temuan sebelumnya dikonfirmasi.

Dalam kenyataan pahit kita, setiap orang yang setidaknya pernah merancang proyek besar setidaknya dalam pikiran mereka memiliki ide sendiri tentang bagaimana melakukannya, dan sering siap untuk mempertahankan ide-ide mereka hingga tetes darah terakhir. Bagi yang lain, itu membuat saya tersenyum, dan manajemen sering melihat semua ini sebagai kotak hitam besar, yang tidak pernah ditentang oleh siapa pun. Tetapi bagaimana jika saya memberi tahu Anda bahwa solusi yang tepat akan membantu mengurangi penciptaan fungsionalitas baru sebanyak 2-3 kali, mencari kesalahan pada 5-10 kali yang lama, dan akan memungkinkan Anda melakukan banyak hal baru dan penting yang sebelumnya tidak dapat diakses? Cukup dengan membiarkan arsitektur masuk ke hati Anda!
Solusi arsitektur untuk gim seluler. Bagian 2: Perintah dan antriannya
Solusi arsitektur untuk gim seluler. Bagian 3: Lihat di dorongan jet


Model


Akses ke bidang


Sebagian besar programmer menyadari pentingnya menggunakan sesuatu seperti MVC. Hanya sedikit orang yang menggunakan MVC murni dari buku sekelompok empat, tetapi semua keputusan kantor normal entah bagaimana mirip dengan pola semangat ini. Hari ini kita akan berbicara tentang huruf pertama dari singkatan ini. Karena sebagian besar pekerjaan pemrogram dalam game mobile adalah fitur baru dalam meta-game, diimplementasikan sebagai manipulasi dengan model, dan mengacaukan ribuan antarmuka ke dalam fitur ini. Dan kenyamanan model memainkan peran kunci dalam pelajaran ini.

Saya tidak memberikan kode lengkap, karena ini sedikit dofig, dan secara umum ini bukan tentang dia. Saya akan mengilustrasikan alasan saya dengan contoh sederhana:

public class PlayerModel { public int money; public InventoryModel inventory; /* Using */ public void SomeTestChanges() { money = 10; inventory.capacity++; } } 

Opsi ini sama sekali tidak cocok untuk kita, karena model tidak mengirimkan peristiwa tentang perubahan yang terjadi di dalamnya. Jika informasi tentang bidang mana yang dipengaruhi oleh perubahan, dan mana yang tidak, dan mana yang perlu digambar ulang, dan mana yang tidak, programmer akan menentukan secara manual dalam satu atau lain bentuk - ini akan menjadi sumber utama kesalahan dan waktu yang dihabiskan. Dan tidak perlu membuat mata terkejut. Di sebagian besar kantor besar tempat saya bekerja, programmer mengirim semua jenis InventoryUpdatedEvent sendiri, dan dalam beberapa kasus juga mengisinya secara manual. Beberapa dari kantor-kantor ini menghasilkan jutaan, menurut Anda, terima kasih atau meskipun demikian?

Kami akan menggunakan kelas kami sendiri ReactiveProperty <T> yang akan menyembunyikan di bawah kap semua manipulasi untuk mengirim pesan yang kami butuhkan. Akan terlihat seperti ini:

 public class PlayerModel : Model { public ReactiveProperty<int> money = new ReactiveProperty<int>(); public ReactiveProperty<InventoryModel> inventory = new ReactiveProperty<InventoryModel>(); /* Using */ public void SomeTestChanges() { money.Value = 10; inventory.Value.capacity.Value++; } public void Subscription(Text text) { money.SubscribeWithState(text, (x, t) => t.text = x.ToString()); } } 

Ini adalah versi pertama dari model. Opsi ini sudah menjadi impian bagi banyak programmer, tetapi saya masih tidak menyukainya. Hal pertama yang saya tidak suka adalah mengakses nilai itu rumit. Saya berhasil menjadi bingung ketika menulis contoh ini, melupakan Nilai di satu tempat, dan manipulasi data inilah yang merupakan bagian terbesar dari semua yang dilakukan dan bingung dengan model. Jika Anda menggunakan versi bahasa 4.x, Anda dapat melakukan ini:

 public ReactiveProperty<int> money { get; private set; } = new ReactiveProperty<int>(); 

tapi ini tidak menyelesaikan semua masalah. Saya ingin menulis secara sederhana: inventory.capacity ++;. Misalkan kita mencoba mendapatkan untuk setiap bidang model; mengatur; Tetapi untuk berlangganan acara, kita juga perlu akses ke ReactiveProperty itu sendiri. Ketidaknyamanan yang jelas dan sumber kebingungan. Terlepas dari kenyataan bahwa kita hanya perlu menunjukkan bidang mana yang akan kita pantau. Dan di sini saya datang dengan manuver rumit yang saya sukai.

Mari kita lihat apakah Anda menyukainya.

Bukan ReactiveProperty itu sendiri yang dimasukkan ke dalam model spesifik yang diprogram oleh programmer yang menulis aturan, tetapi deskriptor statis PValue-nya, pewaris Properti yang lebih umum, itu mengidentifikasi bidang, dan di dalam tudung konstruktor Model tersembunyi penciptaan dan penyimpanan ReactiveProperty dari tipe yang diinginkan. Bukan nama terbaik, tapi itu terjadi, lalu diganti namanya.

Dalam kode, tampilannya seperti ini:

 public class PlayerModel : Model { public static PValue<int> MONEY = new PValue<int>(); public int money { get { return MONEY.Get(this); } set { MONEY.Set(this, value) } } public static PModel<InventoryModel> INVENTORY = new PModel<InventoryModel>(); public InventoryModel inventory { get { return INVENTORY.Get(this); } set { INVENTORY.Set(this, value) } } /* Using */ public void SomeTestChanges() { money = 10; inventory.capacity++; } public void Subscription(Text text) { this.Get(MONEY).SubscribeWithState(text, (x, t) => t.text = x.ToString()); } } 

Ini adalah opsi kedua. Nenek moyang umum Model, tentu saja, rumit dengan mengorbankan menciptakan dan mengekstraksi Properti Reaktif nyata menurut deskriptornya, tetapi ini dapat dilakukan dengan sangat cepat dan tanpa refleksi, atau lebih tepatnya, menerapkan refleksi hanya sekali pada tahap inisialisasi kelas. Dan ini adalah pekerjaan yang dilakukan sekali oleh pencipta mesin, dan kemudian akan digunakan oleh semua orang. Selain itu, desain ini menghindari upaya yang tidak disengaja untuk memanipulasi ReactiveProperty itu sendiri alih-alih nilai yang tersimpan di dalamnya. Pembuatan lapangan berantakan, tetapi dalam semua kasus itu persis sama, dan dapat dibuat dengan templat.

Di akhir artikel ada polling pilihan mana yang paling Anda sukai.
Semua yang dijelaskan di bawah ini dapat diimplementasikan di kedua versi.

Transaksi


Saya ingin programmer dapat mengubah bidang model hanya ketika ini diizinkan oleh pembatasan yang diadopsi dalam mesin, yaitu di dalam tim, dan tidak pernah lagi. Untuk melakukan ini, setter harus pergi ke suatu tempat dan memeriksa apakah perintah transaksi saat ini terbuka, dan hanya kemudian memungkinkan informasi yang akan diedit dalam model. Ini sangat diperlukan, karena pengguna mesin secara teratur mencoba melakukan sesuatu yang aneh untuk mem-bypass proses tipikal, mematahkan logika mesin dan menyebabkan kesalahan yang halus. Saya melihat ini lebih dari sekali atau dua kali.

Ada kepercayaan bahwa jika Anda membuat antarmuka terpisah untuk membaca data dari model dan untuk menulis, itu akan membantu. Pada kenyataannya, model ini ditumbuhi dengan file tambahan dan operasi tambahan yang membosankan. Pembatasan ini bersifat final. Pemrogram dipaksa, pertama, untuk mengetahui dan terus-menerus memikirkannya: "apa yang harus diberikan oleh masing-masing fungsi spesifik, model atau antarmuka", dan kedua, situasi juga muncul ketika pembatasan ini harus dielakkan, sehingga di pintu keluar kami memiliki d'Artagnan, yang membuat semuanya serba putih, dan banyak pengguna mesinnya, yang merupakan penjaga buruk dari Manajer Proyek, dan meskipun terus-menerus disalahgunakan, tidak ada yang berfungsi sebagaimana mestinya. Oleh karena itu, saya lebih suka hanya menutup secara ketat kemungkinan kesalahan seperti itu. Kurangi dosis konvensi, jadi untuk berbicara.

Setter ReactiveProperty harus memiliki tautan ke tempat di mana keadaan transaksi saat ini harus diperiksa. Katakanlah tempat ini adalah classCModelRoot. Opsi termudah adalah meneruskannya ke konstruktor model secara eksplisit. Versi kedua kode ketika memanggil RProperty menerima tautan ke ini secara eksplisit, dan dapat memperoleh semua informasi yang diperlukan dari sana. Untuk versi pertama kode, Anda harus menjalankan bidang tipe ReactiveProperty di konstruktor dengan refleksi dan memberi mereka tautan ke sini untuk manipulasi lebih lanjut. Sedikit ketidaknyamanan adalah perlunya membuat konstruktor eksplisit dengan parameter di setiap model, seperti ini:

 public class PlayerModel : Model { public PlayerModel(ModelRoot gamestate) : base (gamestate) {} } 

Tetapi untuk fitur model lainnya, sangat berguna bahwa model memiliki tautan ke model induk, membentuk konstruk yang saling terhubung. Dalam contoh kita, ini akan menjadi player.inventory.Parent == pemain. Dan kemudian konstruktor ini dapat dihindari. Model mana pun akan bisa mendapatkan dan menyimpan tautan ke tempat magis dari orang tuanya, dan yang itu dari orang tuanya, dan seterusnya sampai orang tua berikutnya ternyata menjadi tempat magis itu. Akibatnya, pada tingkat deklarasi, semua ini akan terlihat seperti ini:

 public class ModelRoot : Model { public bool locked { get; private set; } } public partial class Model { public Model Parent { get; protected set; } public ModelRoot Root { get; } } 

Semua keindahan ini akan terisi secara otomatis ketika model memasuki pohon gamestate. Ya, model yang baru dibuat, yang belum sampai di sana, tidak akan dapat belajar tentang transaksi dan memblokir manipulasi dengan sendirinya, tetapi jika keadaan transaksi dilarang, itu tidak akan bisa masuk ke negara setelah itu, pembuat orangtua tidak akan mengizinkannya, sehingga integritas gamestate tidak akan terpengaruh. Ya, ini akan membutuhkan pekerjaan tambahan pada tahap pemrograman mesin, tetapi di sisi lain, seorang programmer yang menggunakan mesin akan sepenuhnya menghilangkan kebutuhan untuk mengetahui dan memikirkannya sampai dia mencoba untuk melakukan sesuatu yang salah dan mendapatkan tangannya.

Karena percakapan tentang transaktivitas telah dimulai, pesan tentang perubahan tidak boleh diproses segera setelah perubahan dilakukan, tetapi hanya ketika semua manipulasi dengan model dalam perintah saat ini selesai. Ada dua alasan untuk ini, yang pertama adalah konsistensi data. Tidak semua status data konsisten secara internal. Mungkin Anda tidak dapat mencoba membuatnya. Atau jika Anda tidak sabar, misalnya, untuk mengurutkan array atau mengubah beberapa variabel model dalam satu lingkaran. Anda seharusnya tidak menerima ratusan pesan perubahan.

Ada dua cara untuk melakukan ini. Yang pertama adalah berlangganan pembaruan ke variabel dan menggunakan fungsi rumit yang menambahkan aliran akhir transaksi ke aliran perubahan dalam variabel dan hanya setelah itu akan melewatkan pesan. Ini cukup mudah dilakukan jika Anda menggunakan UniRX, misalnya. Tetapi opsi ini memiliki banyak kekurangan, khususnya memunculkan banyak gerakan yang tidak perlu. Secara pribadi, saya suka pilihan lain.

Setiap ReactiveProperty akan mengingat statusnya sebelum dimulainya transaksi dan keadaan saat ini. Pesan tentang perubahan dan perbaikan perubahan hanya akan dilakukan di akhir transaksi. Dalam kasus ketika objek perubahan adalah semacam koleksi, ini akan memungkinkan secara eksplisit untuk memasukkan informasi tentang perubahan yang telah terjadi dalam pesan yang dikirim. Misalnya, dua item dalam daftar ditambahkan, dan dua item seperti itu dihapus. Alih-alih hanya mengatakan bahwa sesuatu telah berubah, dan memaksa penerima untuk menganalisis daftar seribu elemen panjangnya dalam mencari informasi yang perlu digambar ulang.

 public partial class Model { public void DispatchChanges(Command transaction); public void FixChanges(); public void RevertChanges(); } 

Opsi ini lebih memakan waktu pada tahap pembuatan mesin, tetapi kemudian biaya penggunaan lebih rendah. Dan yang paling penting, itu membuka kemungkinan untuk perbaikan selanjutnya.

Informasi tentang perubahan yang dilakukan pada model


Saya ingin lebih dari model. Setiap saat saya ingin dengan mudah dan nyaman melihat apa yang telah berubah dalam keadaan model sebagai hasil dari tindakan saya. Misalnya, dalam formulir ini:

 {"player":{"money":10, "inventory":{"capacity":11}}} 

Paling sering, berguna bagi programmer untuk melihat perbedaan antara keadaan model sebelum dimulainya perintah dan setelah berakhirnya, atau pada beberapa titik di dalam perintah. Beberapa untuk kloning ini seluruh gamestate sebelum dimulainya tim, dan kemudian membandingkan. Ini sebagian menyelesaikan masalah pada tahap debugging, tetapi sama sekali tidak mungkin untuk menjalankan ini di produk. Kloning negara, yang menghitung perbedaan tidak signifikan antara kedua daftar, adalah operasi yang sangat mahal untuk dilakukan dengan bersin apa pun.

Oleh karena itu, ReactiveProperty harus menyimpan tidak hanya kondisi saat ini, tetapi juga yang sebelumnya. Ini memunculkan seluruh kelompok peluang yang sangat berguna. Pertama, ekstraksi perbedaan dalam situasi seperti itu cepat, dan kita bisa dengan tenang membuang semuanya ke dalam makanan. Kedua, Anda tidak bisa mendapatkan perbedaan besar, tetapi sedikit hash dari perubahan, dan membandingkannya dengan hash perubahan di gamestate lain yang sama. Jika tidak setuju, Anda memiliki masalah. Ketiga, jika eksekusi perintah jatuh dengan eksekusi, Anda selalu dapat membatalkan perubahan dan mencari tahu tentang kondisi yang belum terjamah pada saat transaksi dimulai. Bersama dengan tim yang diterapkan pada negara bagian, informasi ini sangat berharga karena Anda dapat dengan mudah mereproduksi situasi secara akurat. Tentu saja, untuk ini Anda harus memiliki fungsionalitas siap pakai untuk serialisasi yang nyaman dan deserialisasi keadaan permainan, tetapi Anda tetap akan membutuhkannya.

Serialisasi perubahan model


Mesin menyediakan serialisasi dan biner, dan di json - dan ini bukan kecelakaan. Tentu saja, serialisasi biner memakan banyak ruang lebih sedikit dan bekerja lebih cepat, yang penting, terutama selama boot awal. Tapi ini bukan format yang bisa dibaca manusia, dan di sini kami berdoa untuk kemudahan debugging. Selain itu, ada jebakan lain. Saat gim Anda masuk ke prod, Anda harus terus beralih dari versi ke versi. Jika programmer Anda mengikuti beberapa tindakan pencegahan sederhana dan tidak menghapus apa pun dari kondisi permainan secara tidak perlu, Anda tidak akan merasakan transisi ini. Dan dalam format biner, tidak ada nama string bidang untuk alasan yang jelas, dan jika versi tidak cocok, Anda harus membaca biner dengan versi lama negara, mengekspornya ke sesuatu yang lebih informatif, misalnya, json yang sama, kemudian mengimpornya ke negara baru, mengekspornya ke biner, tulis, dan hanya setelah semua ini bekerja lebih lanjut seperti biasa. Akibatnya, dalam beberapa proyek, konfigurasi ditulis ke biner mengingat ukurannya yang siklope, dan mereka sudah lebih suka menyeret negara bolak-balik dalam bentuk json. Nilai overhead dan pilih Anda.

 [Flags] public enum ExportMode { all = 0x0, changes = 0x1, serverVerified = 0x2, //    ,    } /**    */ public partial class Model { public bool GetHashCode(ExportMode mode, out int code); public bool Import(BinaryReader binarySerialization); public bool Import(JSONReader json); public void ExportAll(ExportMode mode, BinaryWriter binarySerialization); public void ExportAll(ExportMode mode, JSONWriter json); public bool Export(ExportMode mode, out Dictionary<string, object> data); } 

Tanda tangan dari metode Ekspor (mode ExportMode, keluar Kamus <string, objek> data) agak mengkhawatirkan. Dan masalahnya adalah ini: Ketika Anda membuat serial seluruh pohon, Anda dapat segera menulis ke aliran, atau dalam kasus kami, untuk JSONWriter, yang merupakan add-on sederhana untuk StringWriter. Tetapi ketika Anda mengekspor perubahan, itu tidak sesederhana itu, karena ketika Anda pergi jauh ke dalam pohon dan pergi ke salah satu cabang, Anda masih tidak tahu apakah akan mengekspor sesuatu dari itu sama sekali. Oleh karena itu, pada tahap ini saya datang dengan dua solusi, satu lebih sederhana, yang kedua lebih rumit dan ekonomis. Yang lebih sederhana adalah ketika mengekspor hanya perubahan, Anda mengubah semua perubahan menjadi pohon dari Kamus <string, objek> dan Daftar <objek]. Dan kemudian apa yang terjadi, beri makan serializer favorit Anda. Ini adalah pendekatan sederhana yang tidak memerlukan menari dengan rebana. Tetapi kekurangannya adalah bahwa dalam proses mengekspor perubahan ke tumpukan, tempat untuk koleksi satu kali akan dialokasikan. Faktanya, tidak ada banyak ruang, karena ekspor lengkap ini menghasilkan pohon besar, dan perintah tipikal menyisakan sedikit perubahan pada pohon.

Namun, banyak orang percaya bahwa memberi makan Pengumpul Sampah sebagai troll itu tidak perlu tanpa kebutuhan ekstrim. Bagi mereka, dan untuk menenangkan hati nurani saya, saya menyiapkan solusi yang lebih kompleks:

 /**    */ public partial class Model { public void ExportAll(ExportMode mode, Type propertyType, JSONWriter writer, bool newModel = false); public bool DetectChanges(ExportMode mode, Stack<Model> ierarchyChanged = null); public void ExportChanges(ExportMode mode, Type propertyType, JSONWriter writer, Queue<Model> ierarchyChanges = null); } 

Inti dari metode ini adalah berjalan melewati pohon dua kali. Untuk pertama kalinya, lihat semua model yang telah mengubah diri mereka sendiri, atau ada perubahan dalam model anak, dan tulis semuanya dalam Antrian <Model> ierarchyChanges persis dalam urutan di mana mereka muncul di pohon dalam keadaan saat ini. Tidak banyak perubahan, antrian tidak akan lama. Selain itu, tidak ada yang mencegah untuk menjaga Stack <Model> dan Antrian <Model> antara panggilan dan kemudian akan ada alokasi yang sangat sedikit selama panggilan.

Dan sudah melewati yang kedua kalinya melalui pohon, akan mungkin untuk melihat bagian atas dari antrian setiap kali, dan memahami apakah perlu untuk masuk ke cabang pohon ini atau segera melanjutkan. Ini memungkinkan JSONWriter untuk segera menulis tanpa mengembalikan hasil perantara lainnya.

Sangat mungkin bahwa komplikasi ini tidak benar-benar diperlukan, karena nanti Anda akan melihat bahwa mengekspor perubahan ke pohon yang Anda butuhkan hanya untuk debugging atau ketika menabrak Exception. Selama operasi normal, semuanya terbatas pada GetHashCode (mode ExportMode, kode int keluar) yang semua kesenangan ini sangat asing.

Sebelum kita terus menyulitkan model kita, mari kita bicarakan ini.

Mengapa ini sangat penting?


Semua programmer mengatakan ini sangat penting, tetapi biasanya tidak ada yang percaya. Mengapa

Pertama, karena semua programmer mengatakan bahwa Anda perlu membuang yang lama dan menulis yang baru. Itu saja, terlepas dari kualifikasi. Tidak ada cara manajerial untuk mengetahui apakah ini benar atau tidak, dan eksperimen biasanya terlalu mahal. Manajer akan dipaksa untuk memilih satu programmer dan memercayai penilaiannya. Masalahnya adalah penasihat seperti itu biasanya adalah orang yang sudah lama bekerja sama dengan manajemen dan mengevaluasinya dengan apakah ia mampu merealisasikan idenya, dan semua gagasan terbaiknya sudah diwujudkan dalam kenyataan. Jadi ini juga bukan cara yang ideal untuk mengetahui seberapa baik ide orang lain dan berbeda.

Kedua, 80% dari semua game mobile menghasilkan kurang dari $ 500 dalam seluruh hidup mereka. Oleh karena itu, pada awal proyek, manajemen memiliki masalah lain, arsitektur yang lebih penting. Tetapi keputusan yang dibuat pada awal proyek ini menyandera orang dan tidak melepaskan dari enam bulan menjadi tiga tahun. Proses refactoring dan beralih ke ide-ide lain dalam proyek yang sudah berjalan, yang juga memiliki klien, adalah bisnis yang sangat sulit, mahal dan berisiko. Jika untuk sebuah proyek di awal, menginvestasikan tiga bulan dalam arsitektur normal sepertinya merupakan kemewahan yang tidak dapat diterima, lalu apa yang dapat Anda katakan tentang biaya menunda pembaruan dengan fitur baru selama beberapa bulan?

Ketiga, meskipun gagasan “bagaimana seharusnya” itu sendiri baik dan ideal, tidak diketahui berapa lama implementasinya. Ketergantungan waktu yang dihabiskan pada kesejukan programmer sangat tidak linier. Seigneur akan membuat tugas sederhana tidak jauh lebih cepat daripada junior. Satu setengah kali, mungkin. Tetapi setiap programmer memiliki "batas kompleksitas" sendiri, di luar itu efektivitasnya turun secara dramatis. Saya punya kasus dalam hidup saya ketika saya perlu mewujudkan tugas arsitektur yang agak rumit, dan bahkan sepenuhnya berkonsentrasi pada masalah dengan mematikan Internet di rumah dan memesan makanan siap saji selama sebulan tidak membantu.Tetapi dua tahun kemudian, setelah membaca buku-buku menarik dan menyelesaikan tugas-tugas terkait , Saya memecahkan masalah ini dalam tiga hari. Saya yakin semua orang akan mengingat hal seperti itu dalam karier mereka. Dan inilah tangkapannya! Faktanya adalah bahwa jika ide cerdik datang ke pikiran Anda sebagaimana mestinya, maka kemungkinan besar ide baru ini ada di suatu tempat di batas kompleksitas pribadi Anda, dan mungkin bahkan sedikit di belakangnya. Manajemen, setelah berulang kali membakarnya, mulai meniup ide baru. Dan jika kamu membuat game untuk dirimu sendiri, hasilnya bisa lebih buruk, karena tidak akan ada yang menghentikanmu.

Tetapi bagaimana, kemudian, apakah ada yang berhasil menggunakan solusi yang baik? Ada beberapa cara.

Pertama, setiap perusahaan ingin merekrut orang yang sudah jadi yang sudah melakukan ini dengan majikan sebelumnya. Ini adalah cara paling umum untuk mengalihkan beban eksperimen ke orang lain.

Kedua, perusahaan atau orang-orang yang membuat permainan sukses pertama mereka, menyeruput, dan memulai proyek berikutnya siap untuk perubahan.

Ketiga, jujur ​​mengakui pada diri sendiri bahwa kadang-kadang Anda melakukan sesuatu bukan karena gaji, tetapi untuk kesenangan proses. Yang utama adalah mencari waktu untuk ini.

Keempat, itu adalah serangkaian solusi dan perpustakaan yang terbukti, bersama dengan orang-orang, yang menjadi dana utama perusahaan game, dan ini adalah satu-satunya hal yang akan tetap ada di dalamnya ketika beberapa orang kunci berhenti dan pindah ke Australia.

Yang terakhir, meskipun bukan alasan yang paling jelas: karena itu sangat bermanfaat. Solusi yang baik menyebabkan pengurangan beberapa waktu untuk menulis fitur baru, men-debug mereka dan menangkap kesalahan. Izinkan saya memberi Anda sebuah contoh: dua hari yang lalu, klien memiliki eksekusi dalam fitur baru, probabilitasnya adalah 1 dari 1000, yaitu, QA akan menyiksa untuk memperbanyaknya, dan jika Anda memberikannya, itu adalah 200 pesan kesalahan per hari. Berapa lama waktu yang Anda perlukan untuk mereproduksi situasi dan menangkap klien pada breakpoint satu baris sebelum semuanya runtuh? Sebagai contoh, saya punya 10 menit.

Model


Pohon Model


Modelnya terdiri dari banyak objek. Pemrogram yang berbeda memutuskan secara berbeda bagaimana menghubungkan mereka bersama. Cara pertama adalah ketika model diidentifikasi oleh tempat di mana ia berada. Ini sangat mudah dan sederhana ketika referensi ke model milik satu tempat di ModelRoot. Mungkin bahkan dapat digeser dari satu tempat ke tempat lain, tetapi dua tautan dari tempat yang berbeda tidak pernah mengarah ke sana. Kami akan melakukan ini dengan memperkenalkan versi baru dari deskriptor ModelProperty yang akan menangani tautan dari satu model ke model lain yang berada di dalamnya. Dalam kode tersebut, akan terlihat seperti ini:

 public class PModel<T> : Property<T> where T:Model {} public partial class PlayerModel : Model { public PModel<InventoryModel> INVENTORY = new PModel<InventoryModel>(); public InventoryModel inventory { get { return INVENTORY.Value(this); } set { INVENTORY.Value(this, value); } } } 

Apa bedanya? Ketika model baru ditambahkan ke bidang ini, model yang ditambahkannya ditulis dalam bidang Induknya, dan ketika dihapus, bidang Induk direset. Secara teori, semuanya baik-baik saja, tetapi ada banyak jebakan. Yang pertama - programmer yang akan menggunakannya, bisa salah. Untuk menghindari hal ini, kami memaksakan pemeriksaan tersembunyi pada proses ini, dari sudut yang berbeda:

  1. Kami akan memperbaiki PValue sehingga memeriksa jenis nilainya, dan bersumpah oleh para ahli ketika mencoba untuk menyimpan referensi ke model di dalamnya, menunjukkan bahwa untuk ini perlu menggunakan konstruksi yang berbeda, hanya agar tidak bingung. Ini, tentu saja, adalah pemeriksaan runtime, tetapi bersumpah pada upaya pertama untuk memulai, jadi itu akan dilakukan.
  2. PModel Parent - , . . , .

Efek samping muncul dari ini, jika Anda perlu menggeser model seperti itu dari satu tempat ke tempat lain, Anda harus terlebih dahulu menghapusnya dari tempat pertama, dan baru kemudian menambahkannya ke tempat kedua - jika tidak maka cek akan memarahi Anda. Tetapi ini sebenarnya jarang terjadi.

Karena model terletak di satu tempat yang didefinisikan secara ketat dan memiliki referensi ke induknya, kami dapat menambahkan metode baru - ia dapat mengetahui ke arah mana ia berada di pohon ModelRoot. Ini sangat nyaman untuk debugging, tetapi juga diperlukan agar dapat diidentifikasi secara unik. Misalnya, cari model lain yang sama persis di gamestate lain yang sama, atau tunjukkan dalam perintah yang dikirimkan ke server tautan yang berisi model yang berisi perintah itu. Itu terlihat seperti ini:

 public class ModelPath { public Property[] properties; public Object[] indexes; public override ToString(); public static ModelPath FromString(string path); } public partial class Model { public ModelPath Path(); } public partial class ModelRoot : Model { public Model GetByPath(ModelPath path); } 

Dan mengapa, pada kenyataannya, tidak mungkin memiliki objek berakar di satu tempat, dan untuk merujuknya dari yang lain? Dan karena Anda membayangkan bahwa Anda deserializing objek dari JSON, dan di sini Anda akan menemukan tautan ke objek yang berakar di tempat yang sama sekali berbeda. Dan masih belum ada tempat untuk itu, itu hanya akan dibuat melalui lantai deserialisasi. Ups Tolong jangan menawarkan deserialization multi-pass. Ini adalah batasan dari metode ini. Oleh karena itu, kami akan membuat metode kedua:

Semua model yang dibuat dengan metode kedua dibuat di satu tempat ajaib, dan di semua tempat lain hanya tautan gamestate yang dimasukkan. Selama deserialisasi, jika ada beberapa referensi ke objek, saat pertama kali Anda mengakses tempat ajaib, objek dibuat, dan dengan semua referensi berikutnya ke objek yang sama dikembalikan. Untuk mengimplementasikan fitur-fitur lain, kami berasumsi bahwa permainan dapat memiliki beberapa gamestate, jadi tempat ajaib seharusnya tidak menjadi satu yang umum, tetapi harus ditempatkan, misalnya, dalam gamestate. Untuk referensi ke model tersebut, kami menggunakan variasi lain dari deskriptor PPersistent. Model itu sendiri akan dibuat lebih spesial oleh Persistent: Model. Dalam kode, akan terlihat seperti ini:

 public class Persistent : Model { public int id { get { return ID.Get(this); } set { ID.Set(this, value); } } public static RProperty<int> ID = new RProperty<int>(); } public partial class ModelRoot : Model { public int nextFreePersistentId { get { return NEXT_FREE_PERSISTENT_ID.Get(this); } set { NEXT_FREE_PERSISTENT_ID.Set(this, value); } } public static RProperty<int> NEXT_FREE_PERSISTENT_ID = new RProperty<int>(); public static PDictionaryModel<int, Persistent> PERSISTENT = new PDictionaryModel<int, Persistent>() { notServerVerified = true }; /// <summary>      Id-. </summary> public PersistentT Persistent<PersistentT>(int localId) where PersistentT : Persistent, new(); /// <summary> C    Id. </summary> public PersistentT Persistent<PersistentT>() where PersistentT : Persistent, new(); } 

Sedikit rumit, tetapi bisa digunakan. Untuk meletakkan sedotan, Persistent dapat mengencangkan konstruktor dengan parameter ModelRoot, yang akan membangkitkan alarm jika mereka mencoba membuat model ini bukan melalui metode ModelRoot ini.

Saya memiliki kedua opsi dalam kode saya, dan pertanyaannya adalah, mengapa kemudian menggunakan opsi pertama jika yang kedua sepenuhnya mencakup semua kasus yang mungkin?

Jawabannya adalah bahwa keadaan permainan harus, pertama-tama, dapat dibaca oleh orang-orang. Bagaimana kelihatannya jika, jika memungkinkan, opsi pertama digunakan?

 { "persistents":{}, "player":{ "money":10, "inventory":{"capacity":11} } } 

Dan sekarang, bagaimana jadinya jika hanya opsi kedua yang digunakan:
 { "persistents":{ "1":{"money":10, "inventory":2}, "2":{"capacity":11} }, "player":1 } 

Untuk debug pribadi, saya lebih suka opsi pertama.

Akses Model Properties


Akses ke fasilitas penyimpanan reaktif untuk properti pada akhirnya ternyata disembunyikan di bawah kap model. Tidak terlalu jelas bagaimana membuatnya bekerja begitu cepat, tanpa terlalu banyak kode pada model akhir dan tanpa terlalu banyak refleksi. Mari kita lihat lebih dekat.

Hal pertama yang berguna untuk diketahui tentang Kamus adalah membaca dari situ tidak membutuhkan banyak waktu yang konstan, terlepas dari ukuran kamus. Kami akan membuat kamus statis pribadi di Model di mana setiap jenis model diberikan deskripsi bidang mana yang ada di dalamnya dan kami akan mengaksesnya satu kali saat membuat model. Dalam konstruktor tipe, kita melihat apakah ada deskripsi untuk tipe kita. Jika tidak, kita buat, jika demikian, kita ambil yang selesai. Dengan demikian, deskripsi hanya akan dibuat sekali untuk setiap kelas. Saat membuat deskripsi, kami memasukkan setiap Properti statis (deskripsi bidang) data yang diekstraksi melalui refleksi - nama bidang, dan indeks tempat penyimpanan data untuk bidang ini dalam array. Dengan cara iniketika diakses melalui deskripsi lapangan, penyimpanannya akan dikeluarkan dari array pada indeks yang sebelumnya dikenal, yaitu, dengan cepat.

Dalam kode tersebut, akan terlihat seperti ini:

 public class Model : IModelInternals { #region Properties protected static Dictionary<Type, Property[]> propertiesDictionary = new Dictionary<Type, Property[]>(); protected static Dictionary<Type, Property[]> propertiesForBinarySerializationDictionary = new Dictionary<Type, Property[]>(); protected Property[] _properties, _propertiesForBinarySerialization; protected BaseStorage[] _storages; public Model() { Type targetType = GetType(); if (!propertiesDictionary.ContainsKey(targetType)) RegisterModelsProperties(targetType, new List<Property>(), new List<Property>()); _properties = propertiesDictionary[targetType]; _storages = new BaseStorage[_properties.Length]; for (var i = 0; i < _storages.Length; i++) _storages[i] = _properties[i].CreateStorage(); } private void RegisterModelsProperties(Type target, List<Property> registered, List<Property> registeredForBinary) { if (!propertiesDictionary.ContainsKey(target)) { if (target.BaseType != typeof(Model) && typeof(Model).IsAssignableFrom(target.BaseType)) RegisterModelsProperties(target.BaseType, registered, registeredForBinary); var fields = target.GetFields(BindingFlags.Public | BindingFlags.Static); // | BindingFlags.DeclaredOnly List<Property> alphabeticSorted = new List<Property>(); for (int i = 0; i < fields.Length; i++) { var field = fields[i]; if (typeof(Property).IsAssignableFrom(field.FieldType)) { var prop = field.GetValue(this) as Property; prop.Name = field.Name; prop.Parent = target; prop.storageIndex = registered.Count; registered.Add(prop); alphabeticSorted.Add(prop); } } alphabeticSorted.Sort((p1, p2) => String.Compare(p1.Name, p2.Name)); registeredForBinary.AddRange(alphabeticSorted); Property[] properties = new Property[registered.Count]; for (int i = 0; i < registered.Count; i++) properties[i] = registered[i]; propertiesDictionary.Add(target, properties); properties = new Property[registered.Count]; for (int i = 0; i < registeredForBinary.Count; i++) properties[i] = registeredForBinary[i]; propertiesForBinarySerializationDictionary.Add(target, properties); } else { registered.AddRange(propertiesDictionary[target]); registeredForBinary.AddRange(propertiesForBinarySerializationDictionary[target]); } } CastType IModelInternals.GetStorage<CastType>(Property property) { try { return (CastType)_storages[property.storageIndex]; } catch { UnityEngine.Debug.LogError(string.Format("{0}.GetStorage<{1}>({2})",GetType().Name, typeof(CastType).Name, property.ToString())); return null; } } #endregion } 

Desainnya sedikit sederhana, karena deskriptor properti statis yang dideklarasikan pada leluhur model ini mungkin sudah memiliki indeks penyimpanan terdaftar, dan urutan pengembalian properti dari Type.GetFields () tidak dijamin. Untuk pesanan dan agar properti tidak diinisialisasi ulang dalam dua kali, Anda perlu memonitor diri sendiri.

Properti Pengumpulan


Di bagian pohon model, orang bisa melihat konstruksi yang tidak disebutkan sebelumnya: PDictionaryModel <int, Persistent> - deskriptor untuk bidang yang berisi koleksi. Jelas bahwa kita harus membuat repositori kita sendiri untuk koleksi, yang menyimpan informasi tentang bagaimana koleksi terlihat sebelum awal transaksi dan seperti apa sekarang. Kerikil bawah laut di sini adalah ukuran Batu Guntur di bawah Peter I. Terdiri dari fakta bahwa, dengan memiliki dua kamus panjang, adalah tugas yang sangat mahal untuk menghitung perbedaan di antara mereka. Saya berasumsi bahwa model seperti itu harus digunakan untuk semua tugas yang berhubungan dengan meta, yang berarti mereka harus bekerja dengan cepat. Alih-alih menyimpan dua status, mengkloningnya, dan kemudian membandingkannya dengan mahal, saya membuat kait yang rumit - hanya kondisi kamus saat ini yang disimpan di toko. Dua kamus lainnya adalah nilai yang dihapus,dan nilai-nilai lama dari elemen yang diganti. Akhirnya, Set kunci baru yang ditambahkan ke kamus disimpan. Informasi ini mudah dan cepat diisi. Sangat mudah untuk menghasilkan semua perbedaan yang diperlukan dengannya, dan cukup untuk memulihkan keadaan sebelumnya jika perlu. Dalam kode, tampilannya seperti ini:

 public class DictionaryStorage<TKey, TValues> : BaseStorage { public Dictionary<TKey, TValues> current = new Dictionary<TKey, TValues>(); public Dictionary<TKey, TValues> removed = new Dictionary<TKey, TValues>(); public Dictionary<TKey, TValues> changedValues = new Dictionary<TKey, TValues>(); public HashSet<TKey> newKeys = new HashSet<TKey>(); } 

Saya tidak berhasil membuat repositori indah yang sama untuk Daftar, atau saya tidak punya cukup waktu, saya menyimpan dua salinan. Pengaya tambahan diperlukan untuk mencoba meminimalkan ukuran diff.

 public class ListStorage<TValue> : BaseStorage { public List<TValue> current = new List<TValue>(); public List<TValue> previouse = new List<TValue>(); //        public List<int> order = new List<int>(); //       . } 

Total


Jika Anda benar-benar tahu apa yang ingin Anda terima dan caranya, Anda dapat menulis semua ini dalam beberapa minggu. Kecepatan pengembangan gim pada saat yang sama berubah begitu dramatis sehingga ketika saya mencobanya, saya bahkan tidak memulai gim pembuat gim sendiri tanpa mesin yang bagus. Hanya karena pada bulan pertama investasi untuk saya jelas terbayar. Tentu saja, ini hanya berlaku untuk meta. Gameplay harus dilakukan dengan cara lama.

Di bagian selanjutnya dari artikel ini, saya akan berbicara tentang perintah, jaringan, dan memprediksi respons server. Dan saya juga memiliki beberapa pertanyaan untuk Anda yang sangat penting bagi saya. Jika jawaban Anda berbeda dari yang diberikan dalam tanda kurung, saya akan dengan senang hati membacanya di komentar atau mungkin Anda bahkan menulis artikel. Terima kasih sebelumnya atas jawabannya.

PS Proposal untuk kerja sama dan instruksi tentang berbagai kesalahan sintaksis, silakan di PM.

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


All Articles