Ada saat-saat dalam kehidupan setiap programmer ketika dia bermimpi membuat game yang menarik. Banyak programmer yang mewujudkan mimpi-mimpi ini, dan bahkan berhasil, tetapi ini bukan tentang mereka. Ini tentang mereka yang suka bermain game, yang (bahkan tanpa pengetahuan dan pengalaman) mencoba membuat mereka sekali, terinspirasi oleh contoh-contoh pahlawan tunggal yang mencapai ketenaran di seluruh dunia (dan keuntungan besar), tetapi jauh di lubuk hati memahami bahwa bersaing dengan guru igrostroya yang dia tidak mampu.
Dan jangan ...
Pengantar kecil
Saya akan segera melakukan reservasi: tujuan kami bukanlah menghasilkan uang - ada banyak artikel tentang topik ini di Habré. Tidak, kami akan membuat game impian.
Penyimpangan liris tentang permainan mimpiBerapa kali saya mendengar kata ini dari pengembang tunggal dan studio kecil. Di mana pun Anda melihat, semua igrodelov pemula segera mengungkapkan mimpi dan "visi sempurna" kepada dunia, dan kemudian menulis artikel panjang tentang upaya heroik mereka, proses kerja, kesulitan keuangan yang tak terhindarkan, masalah dengan penerbit dan umumnya "pemain-tidak tahu berterima kasih-anjing-im-" berikan-grafik-dan-koin-dan-semua-gratis-dan-bayar-jangan-tidak-ingin-permainan-bajak laut-dan-kita-telah-kehilangan-keuntungan-karena mereka-di sini. "
Orang, jangan tertipu. Anda tidak membuat game impian, tetapi game yang akan laris manis - ini adalah dua hal yang berbeda. Pemain (dan terutama yang canggih) tidak peduli dengan impian Anda dan mereka tidak akan membayar untuk itu. Jika Anda menginginkan keuntungan - tren belajar, lihat apa yang populer sekarang, lakukan sesuatu yang unik, lakukan lebih baik, lebih tidak biasa daripada yang lain, baca artikel (ada banyak), berkomunikasi dengan penerbit - secara umum, wujudkan impian pengguna akhir, bukan impian Anda.
Jika Anda belum melarikan diri dan masih ingin mewujudkan permainan impian Anda, berikan keuntungan di muka. Sama sekali tidak menjual impian Anda - bagikan secara gratis. Berikan orang-orang impian Anda, bawa mereka ke sana, dan jika impian Anda bernilai sesuatu, Anda akan menerima, jika bukan uang, tetapi cinta dan pengakuan. Ini terkadang jauh lebih berharga.
Banyak orang berpikir bahwa permainan adalah buang-buang waktu dan energi, dan orang yang serius tidak boleh membicarakan topik ini sama sekali. Tetapi orang-orang yang berkumpul di sini tidak serius, jadi kami setuju hanya sebagian - permainan benar-benar membutuhkan banyak waktu jika Anda memainkannya. Namun, pengembangan game, walaupun membutuhkan waktu berkali-kali lebih banyak, dapat membawa banyak manfaat. Sebagai contoh, ini memungkinkan Anda untuk berkenalan dengan prinsip, pendekatan, dan algoritma yang tidak ditemukan dalam pengembangan aplikasi non-game. Atau memperdalam keterampilan memiliki alat (misalnya, bahasa pemrograman), melakukan sesuatu yang tidak biasa dan menarik. Saya sendiri dapat menambahkan (dan banyak yang akan setuju) bahwa pengembangan game (bahkan tidak berhasil) selalu merupakan pengalaman istimewa dan tak tertandingi, yang kemudian Anda ingat dengan keraguan dan cinta, yang ingin saya alami untuk setiap pengembang setidaknya sekali dalam hidup saya.
Kami tidak akan menggunakan mesin permainan, kerangka kerja, perpustakaan baru-ketinggalan jaman - kami akan melihat esensi gameplay dan merasakannya dari dalam. Kami menyerah metodologi pengembangan yang fleksibel (tugas disederhanakan oleh kebutuhan untuk mengatur pekerjaan hanya satu orang). Kami tidak akan menghabiskan waktu dan energi untuk mencari desainer, artis, komposer, dan spesialis dalam suara - kami akan melakukan semuanya sendiri semampu kami (tetapi pada saat yang sama kami akan melakukan segalanya dengan bijak - jika kami tiba-tiba memiliki seorang seniman, kami tidak akan melakukan banyak upaya untuk mempercepat yang modis gambar pada bingkai yang sudah jadi). Pada akhirnya, kami bahkan tidak akan benar-benar mempelajari alat dan memilih yang tepat - kami akan melakukannya pada alat yang kami tahu dan tahu cara menggunakannya. Misalnya, di Jawa, sehingga nanti, jika perlu, transfer ke Android (atau ke pembuat kopi).
"Ah !!! Horor! Mimpi buruk! Bagaimana Anda bisa menghabiskan waktu dengan omong kosong seperti itu! Keluar dari sini, aku akan membaca sesuatu yang lebih menarik! ”Kenapa melakukan ini? Maksudku, menemukan kembali roda? Mengapa tidak menggunakan mesin game yang sudah jadi? Jawabannya sederhana: kami tidak tahu apa-apa tentang dia, tetapi kami ingin permainan sekarang. Bayangkan pola pikir rata-rata programmer: “Saya ingin membuat game! Akan ada daging, ledakan, dan pemompaan,
dan Anda bisa merampok korovan , dan plotnya adalah pengeboman, dan ini tidak pernah terjadi di tempat lain! Saya akan mulai menulis sekarang! .. Dan apa? Mari kita lihat apa yang populer dengan kita sekarang ... Ya, X, Y dan Z. Mari kita ambil Z, sekarang semua orang menulis di atasnya ... ". Dan mulai mempelajari mesinnya. Dan dia melempar idenya, karena sudah tidak cukup waktu untuk itu. Sirip. Atau, oke, itu tidak menyerah, tetapi tanpa benar-benar mempelajari mesin, itu mulai bermain. Nah, kalau begitu ia memiliki hati nurani untuk tidak menunjukkan siapa pun "kerajinan" pertamanya. Biasanya tidak (pergi ke toko aplikasi mana pun, lihat sendiri) - well, well, saya ingin untung, tidak ada kekuatan untuk bertahan. Begitu penciptaan game ternyata banyak orang kreatif yang antusias. Sayangnya, kali ini telah berlalu - sekarang hal utama dalam permainan bukanlah jiwa, tetapi model bisnis (setidaknya ada lebih banyak percakapan tentang hal itu). Tujuan kami sederhana: kami akan membuat game dengan jiwa. Oleh karena itu, kami abstrak dari alat (siapa pun akan melakukannya) dan fokus pada tugas.
Jadi, mari kita lanjutkan.
Saya tidak akan membahas detail pengalaman pahit saya sendiri, tetapi saya akan mengatakan bahwa salah satu masalah utama bagi seorang programmer dalam mengembangkan game adalah grafik. Programmer biasanya tidak tahu cara menggambar (meskipun ada pengecualian), dan seniman biasanya tidak tahu cara membuat program (meskipun ada pengecualian). Dan tanpa grafis, Anda harus mengakui, gim yang langka dilewati. Apa yang harus dilakukan
Ada beberapa pilihan:
1. Gambar semuanya sendiri dalam editor grafis sederhana
Screenshot dari game "Kill Him All", 2003 2. Gambar semuanya sendiri dalam vektor
Screenshot dari game "Raven", 2001
Screenshot dari game "Inferno", 2002 3. Tanyakan kepada saudara yang juga tidak tahu cara menggambar (tetapi apakah itu sedikit lebih baik)
Screenshot dari game "Sialan", 2004 4. Unduh beberapa program untuk pemodelan 3D dan tarik aset dari sana
Screenshot dari game "Fucking 2. Demo", 2006 5. Dalam keputusasaan, merobek rambut di kepala
Screenshot dari game "Sialan", 2004 6. Gambar semuanya sendiri dalam pseudografi (ASCII)
Screenshot dari game "FIFA", 2000
Screenshot dari game "Sumo", 1998 Mari kita memikirkan yang terakhir (sebagian karena tidak terlihat menyedihkan seperti yang lain). Banyak gamer yang tidak berpengalaman percaya bahwa gim tanpa grafis modern yang keren tidak mampu memenangkan hati para pemain - bahkan nama gim tersebut bahkan tidak mengubahnya menjadi gim. Pengembang mahakarya seperti
ADOM ,
NetHack , dan
Dwarf Fortress secara diam-diam menolak argumen tersebut. Penampilan tidak selalu merupakan faktor penentu, penggunaan
ASCII memberikan beberapa keuntungan menarik:
- dalam proses pengembangan, programmer berfokus pada gameplay, mekanik game, komponen plot dan banyak lagi, tanpa terganggu oleh hal-hal kecil;
- mengembangkan komponen grafis tidak memakan waktu terlalu lama - prototipe yang berfungsi (yaitu versi dengan memainkan yang dapat Anda pahami, tetapi apakah layak untuk melanjutkan) akan siap jauh lebih awal;
- tidak perlu mempelajari kerangka kerja dan mesin grafis;
- grafik Anda tidak akan menjadi usang dalam lima tahun Anda akan mengembangkan game;
- pekerja keras akan dapat mengevaluasi produk Anda bahkan pada platform yang tidak memiliki lingkungan grafis;
- jika semuanya dilakukan dengan benar, maka grafik yang keren dapat diikat nanti, nanti.
Pengantar panjang di atas dimaksudkan untuk membantu igrodelov pemula mengatasi ketakutan dan prasangka, berhenti khawatir dan masih mencoba melakukan sesuatu seperti itu. Apakah kamu siap Kalau begitu mari kita mulai.
Langkah pertama. Ide
Bagaimana? Masih belum tahu?
Matikan komputer, pergi makan, berjalan, berolahraga. Atau tidur, paling buruk. Untuk datang dengan permainan bukan untuk mencuci jendela - wawasan dalam proses tidak datang. Biasanya ide permainan lahir tiba-tiba, secara tak terduga, ketika Anda tidak memikirkannya sama sekali. Jika ini tiba-tiba terjadi, ambil pensil lebih cepat dan tulis sampai ide terbang. Setiap proses kreatif diterapkan dengan cara ini.
Dan Anda dapat menyalin gim orang lain. Baiklah, salin. Tentu saja, jangan sobek tanpa malu-malu, katakan di setiap sudut seberapa pintar Anda, tetapi gunakan pengalaman orang lain dalam produk Anda. Berapa banyak setelah ini akan tetap secara khusus dari impian Anda di dalamnya adalah pertanyaan sekunder, karena seringkali gamer memiliki ini: mereka menyukai segala sesuatu dalam permainan, kecuali untuk beberapa dua atau tiga hal yang menjengkelkan, tetapi jika dilakukan secara berbeda ... Siapa yang tahu mungkin membawa ke pikiran ide bagus seseorang adalah impian Anda.
Tapi kita akan pergi dengan cara sederhana - misalkan kita sudah punya ide, dan kita belum memikirkannya sejak lama. Sebagai proyek muluk pertama kami, kami akan membuat tiruan dari game bagus dari Obsidian -
Pathfinder Adventures .
“Apa-apaan ini! Ada meja? "Seperti yang mereka katakan,
pourquoi pas? Kami tampaknya telah meninggalkan prasangka, dan karenanya kami dengan berani mulai memperbaiki gagasan itu. Secara alami, kami tidak akan mengkloning game satu lawan satu, tetapi kami akan meminjam mekanika dasar. Selain itu, penerapan permainan kooperatif berbasis giliran memiliki keuntungan:
- itu langkah demi langkah - ini memungkinkan Anda untuk tidak khawatir tentang pengatur waktu, sinkronisasi, pengoptimalan, FPS, dan hal-hal suram lainnya;
- itu kooperatif, yaitu, pemain atau pemain tidak bersaing satu sama lain, tetapi melawan "lingkungan" tertentu bermain sesuai dengan aturan deterministik - ini menghilangkan kebutuhan untuk memprogram AI ( AI ) - salah satu tahap pengembangan game yang paling sulit;
- itu bermakna - permukaan meja umumnya orang-orang aneh, mereka tidak akan bermain apa pun: memberi mereka mekanik yang bijaksana dan gameplay yang menarik - Anda tidak akan keluar dalam satu gambar yang indah (memberikan sesuatu kepada teman, kan?);
- itu dengan plot - banyak e-olahragawan tidak akan setuju, tetapi bagi saya pribadi permainan harus menceritakan kisah yang menarik - seperti buku, hanya menggunakan sarana artistik khusus.
- dia menghibur, yang bukan untuk semua orang - pendekatan yang dijelaskan dapat diterapkan untuk mimpi berikutnya, tidak peduli berapa banyak yang Anda miliki.
Bagi mereka yang tidak terbiasa dengan aturan, pengantar singkat:Pathfinder Adventures adalah versi digital dari permainan kartu papan yang dibuat berdasarkan permainan papan permainan peran (atau lebih tepatnya, seluruh sistem permainan peran) Pathfinder. Pemain (dalam jumlah 1 hingga 6) memilih karakter untuk diri mereka sendiri dan, bersama dengannya, pergi bertualang, dibagi ke dalam sejumlah skenario. Setiap karakter memiliki berbagai jenis kartu yang dimilikinya (seperti: senjata, baju besi, mantra, sekutu, barang, dll.), Dengan bantuan yang dalam setiap skenario ia harus menemukan dan secara brutal menghukum Scoundrel - kartu khusus dengan properti khusus.
Setiap skenario menyediakan sejumlah lokasi atau lokasi (jumlahnya tergantung pada jumlah pemain) yang perlu dikunjungi dan dijelajahi pemain. Setiap lokasi berisi setumpuk kartu yang tertelungkup, yang dieksplorasi oleh karakter pada gilirannya - yaitu, mereka membuka kartu teratas dan mencoba mengatasinya sesuai dengan aturan yang relevan. Selain kartu yang tidak berbahaya yang mengisi kembali dek pemain, deck ini juga mengandung musuh dan rintangan jahat - mereka harus dikalahkan untuk maju lebih jauh. Kartu Scoundrel juga terletak di salah satu deck, tetapi para pemain tidak tahu yang mana - itu harus ditemukan.
Untuk mengalahkan kartu (dan mendapatkan kartu baru), karakter harus lulus uji salah satu karakteristiknya (standar untuk RPG, kekuatan, ketangkasan, kebijaksanaan, dll.) Dengan melempar dadu yang ukurannya ditentukan oleh nilai karakteristik yang sesuai (dari d4 ke d12), menambahkan pengubah (ditentukan aturan dan tingkat pengembangan karakter) dan bermain untuk meningkatkan efek kartu yang sesuai dari tangan. Setelah menang, kartu met dihapus dari permainan (jika itu adalah musuh) atau mengisi ulang tangan pemain (jika itu adalah item) dan kepindahan pergi ke pemain lain. Ketika kalah, karakternya sering rusak, menyebabkan dia membuang kartu dari tangannya. Seorang mekanik yang menarik adalah bahwa kesehatan karakter ditentukan oleh jumlah kartu di geladaknya - segera setelah pemain perlu mengeluarkan kartu dari geladak, tetapi mereka tidak ada di sana, karakternya mati.
Tujuannya adalah, setelah melalui peta lokasi, untuk menemukan dan mengalahkan Scoundrel, setelah sebelumnya memblokir jalannya untuk mundur (Anda dapat mempelajari lebih lanjut tentang ini dan lebih banyak lagi dengan membaca peraturan). Ini perlu dilakukan untuk sementara waktu, yang merupakan kesulitan utama permainan. Jumlah gerakan sangat terbatas dan enumerasi sederhana dari semua kartu yang tersedia tidak mencapai tujuan. Karena itu, Anda harus menerapkan berbagai trik dan teknik pintar.
Ketika skenario terpenuhi, karakter akan tumbuh dan berkembang, meningkatkan karakteristik mereka dan memperoleh keterampilan baru yang bermanfaat. Mengelola dek juga merupakan elemen yang sangat penting dalam permainan, karena hasil dari skenario (terutama pada tahap selanjutnya) biasanya tergantung pada kartu yang dipilih dengan benar (dan pada banyak keberuntungan, tetapi apa yang Anda inginkan dari permainan dengan dadu?).
Secara umum, gim ini menarik, layak, layak diperhatikan, dan, yang penting bagi kami, cukup rumit (perhatikan bahwa saya mengatakan "sulit" bukan dalam arti "sulit") untuk membuatnya menarik untuk mengimplementasikan klonnya.
Dalam kasus kami, kami akan membuat satu perubahan konseptual global - kami akan meninggalkan kartu. Sebaliknya, kami tidak akan menolak sama sekali, tetapi kami akan mengganti kartu dengan kubus, masih dengan ukuran yang berbeda dan warna yang berbeda (secara teknis, itu tidak cukup benar untuk menggunakan "kubus" mereka, karena ada bentuk lain selain segi enam yang benar, tetapi tidak biasa bagi saya untuk memanggil mereka "tulang" dan itu tidak menyenangkan, tetapi menggunakan daisy Amerika adalah tanda rasa tidak enak, jadi mari kita biarkan saja). Sekarang, bukannya deck, pemain akan memiliki tas. Dan lokasi juga akan memiliki tas, dari mana pemain dalam proses penelitian akan mengeluarkan kubus yang sewenang-wenang. Warna kubus akan menentukan jenisnya dan, dengan demikian, aturan untuk lulus tes. Karakteristik pribadi karakter (kekuatan, ketangkasan, dll.), Sebagai akibatnya, akan dihilangkan, tetapi mekanisme baru yang menarik akan muncul (lebih lanjut tentang yang nanti).
Apakah akan menyenangkan untuk bermain? Saya tidak tahu, dan tidak ada yang bisa mengerti ini sampai prototipe yang berfungsi siap. Tapi kami tidak menikmati permainan, tetapi pengembangan, kan? Karena itu, tidak boleh ada keraguan untuk sukses.
Langkah Dua Desain
Memiliki ide hanyalah sepertiga dari cerita. Sekarang penting untuk mengembangkan ide ini. Artinya, jangan berjalan-jalan di taman atau mandi uap, tetapi duduklah di meja, ambil kertas dengan pena (atau buka editor teks favorit Anda) dan dengan hati-hati menulis dokumen desain, dengan susah payah mengerjakan setiap aspek mekanika game. Waktu untuk ini akan mengambil terobosan, jadi jangan berharap untuk menyelesaikan penulisan dalam satu kesempatan. Dan bahkan tidak berharap untuk memikirkan semuanya sekaligus - saat Anda menerapkan, Anda akan melihat kebutuhan untuk membuat banyak perubahan dan perubahan (dan kadang-kadang mengerjakan ulang sesuatu secara global), tetapi beberapa dasar harus ada sebelum proses pengembangan dimulai.
Pada awalnya, dokumen desain Anda akan terlihat seperti ini Dan hanya setelah mengatasi gelombang pertama ide-ide muluk, Anda mengambil kepala, memutuskan struktur dokumen dan mulai mengisinya secara metodis dengan konten (memeriksa setiap detik dengan apa yang telah ditulis untuk menghindari pengulangan yang tidak perlu dan terutama kontradiksi). Secara bertahap, langkah demi langkah, Anda mendapatkan sesuatu yang bermakna dan ringkas,
seperti ini .
Saat mendeskripsikan desain, pilih bahasa yang memudahkan Anda mengekspresikan pikiran, terutama jika Anda bekerja sendiri. Jika Anda perlu melibatkan pengembang pihak ketiga dalam proyek ini, pastikan mereka memahami semua omong kosong kreatif yang terjadi di kepala Anda.
Untuk melanjutkan, saya sangat menyarankan Anda membaca dokumen yang dikutip setidaknya secara diagonal, karena di masa depan saya akan merujuk pada istilah dan konsep yang disajikan di sana, tanpa merinci interpretasi mereka.
"Penulis, bunuh dirimu di tembok. Terlalu banyak surat. "Langkah Tiga Pemodelan
Artinya, semua desain yang sama, hanya lebih detail.
Saya tahu bahwa banyak yang sudah bersemangat untuk membuka IDE dan mulai coding, tetapi bersabarlah sedikit lagi. Ketika ide membanjiri kepala kita, tampaknya kita hanya perlu menyentuh keyboard dan tangan kita akan tergesa-gesa ke angkasa - sebelum kopi punya waktu untuk mendidih di atas kompor, ketika versi kerja aplikasi siap ... untuk pergi ke tempat sampah. Agar tidak menulis ulang hal yang sama berkali-kali (dan terutama untuk tidak memastikan setelah tiga jam pengembangan bahwa tata letak tidak berfungsi dan perlu dimulai lagi), saya sarankan Anda terlebih dahulu memikirkan (dan mendokumentasikan) struktur utama aplikasi.
Karena kami, sebagai pengembang, sangat mengenal pemrograman berorientasi objek (OOP), kami akan menggunakan prinsip-prinsipnya dalam proyek kami. Tetapi untuk OOP tidak ada yang lebih diharapkan daripada memulai pengembangan dengan sekelompok diagram UML yang membosankan. (Anda tidak tahu apa itu
UML ? Saya hampir lupa juga, tapi saya akan mengingatnya dengan senang hati - hanya untuk menunjukkan betapa saya seorang programmer yang rajin, hehe.)
Mari kita mulai dengan diagram use-case. Kami akan menggambarkan cara pengguna kami (pemain) berinteraksi dengan sistem di masa depan:
"Eh ... apa itu semua tentang?"Hanya bercanda, hanya bercanda ... dan, mungkin, saya berhenti bercanda tentang hal ini - ini adalah masalah serius (mimpi, setelah semua). Pada diagram use case, perlu untuk menampilkan kemungkinan bahwa sistem menyediakan kepada pengguna. Secara detail. Tetapi itu terjadi secara historis bahwa jenis diagram ini adalah yang terburuk bagi saya - tampaknya kesabaran tidak cukup. Dan Anda tidak perlu menatap saya seperti itu - kami tidak di universitas yang melindungi ijazah, tetapi kami menikmati proses kerjanya. Dan untuk proses ini, kasus penggunaan tidak begitu penting. Jauh lebih penting untuk membagi aplikasi dengan benar menjadi modul independen, yaitu, untuk mengimplementasikan game sedemikian rupa sehingga fitur antarmuka visual tidak mempengaruhi mekanik game, dan bahwa komponen grafis dapat dengan mudah diubah jika diinginkan.
Poin ini dapat dirinci dalam diagram komponen berikut:
Di sini kita telah mengidentifikasi subsistem spesifik yang merupakan bagian dari aplikasi kita dan, seperti yang akan ditunjukkan nanti, semuanya akan dikembangkan secara independen satu sama lain.Selain itu, pada tahap yang sama, kami akan mencari tahu seperti apa siklus permainan utama itu (atau lebih tepatnya, bagian yang paling menarik adalah yang mengimplementasikan karakter dalam skrip). Untuk ini, diagram aktivitas cocok untuk kita:Jika Anda berdiri, duduklah Dan akhirnya, alangkah baiknya menyajikan secara umum urutan interaksi pengguna akhir dengan mesin game melalui sistem input-output.Malam itu panjang, jauh sebelum fajar. Setelah duduk sebagaimana mestinya di meja, Anda akan dengan tenang menggambar dua lusin diagram lainnya - percayalah, di masa depan kehadiran mereka akan membantu Anda untuk tetap berada di jalur yang dipilih, meningkatkan harga diri Anda, memperbarui interior ruangan, menggantung wallpaper pudar dengan poster warna-warni, serta menyampaikan visi Anda dengan cara sederhana untuk sesama pengembang yang akan segera bergegas ke pintu studio baru Anda berbondong-bondong (kami tidak bertujuan untuk sukses, ingat?).Sejauh ini kita tidak akan mengutip diagram kelas (kelas) yang kita semua suka - kelas diharapkan untuk menerobos banyak hal dan gambar dalam tiga layar kejelasan pada awalnya tidak akan menambahkan. Lebih baik memecahnya menjadi beberapa bagian dan meletakkannya secara bertahap, saat Anda beralih ke mengembangkan subsistem yang sesuai.Langkah Empat Pemilihan alat
Seperti yang telah disepakati, kami akan mengembangkan aplikasi lintas platform yang berjalan di desktop yang menjalankan berbagai sistem operasi dan di perangkat seluler. Kami akan memilih Jawa sebagai bahasa pemrograman, dan Kotlin lebih baik, karena yang terakhir lebih baru dan lebih segar, dan belum sempat berenang dalam gelombang kemarahan yang membanjiri pendahulunya (pada saat yang sama saya akan belajar jika orang lain tidak memilikinya). JVM , seperti yang Anda tahu, tersedia di mana-mana (pada tiga miliar perangkat, hehe), kami akan mendukung Windows dan UNIX, dan bahkan pada server jarak jauh kami dapat bermain melalui koneksi SSH (tidak diketahui siapa pun yang membutuhkannya, tetapi Kami akan memberikan kesempatan seperti itu). Kami juga akan mentransfernya ke Android ketika kami menjadi kaya dan mempekerjakan seorang artis, tetapi lebih lanjut tentang itu nanti.Perpustakaan (kami tidak bisa ke mana pun tanpa itu) kami akan memilih sesuai dengan kebutuhan lintas platform kami. Kami akan menggunakan Maven sebagai sistem pembangunan. Atau Gradle. Atau sama saja, Maven, mari kita mulai dengan itu. Segera saya menyarankan Anda untuk membuat sistem kontrol versi (mana yang Anda suka), sehingga setelah bertahun-tahun akan lebih mudah untuk mengingat dengan perasaan nostalgia betapa hebatnya dulu. IDE juga memilih yang familier, favorit dan nyaman.Sebenarnya, kami tidak membutuhkan yang lain. Anda bisa mulai berkembang.Langkah Lima Membuat dan mengatur proyek
Jika Anda menggunakan IDE, membuat proyek itu sepele. Anda hanya perlu memilih beberapa nama nyaring (misalnya,
Dadu ) untuk maha karya kami di masa depan, jangan lupa untuk mengaktifkan dukungan Maven di pengaturan, dan menulis pengidentifikasi yang diperlukan dalam file
pom.xml
:
<modelVersion>4.0.0</modelVersion> <groupId>my.company</groupId> <artifactId>dice</artifactId> <version>1.0</version> <packaging>jar</packaging>
Juga tambahkan dukungan Kotlin, yang hilang secara default:
<dependency> <groupId>org.jetbrains.kotlin</groupId> <artifactId>kotlin-stdlib</artifactId> <version>${kotlin.version}</version> </dependency>
dan beberapa pengaturan yang tidak akan kita bahas secara terperinci:
<properties> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <maven.compiler.source>1.8</maven.compiler.source> <maven.compiler.target>1.8</maven.compiler.target> <kotlin.version>1.3.20</kotlin.version> <kotlin.compiler.incremental>true</kotlin.compiler.incremental> </properties>
Sedikit informasi mengenai proyek hybridJika Anda berencana untuk menggunakan Java dan Kotlin dalam proyek Anda, maka selain
src/main/kotlin
, Anda juga akan memiliki folder
src/main/java
. Pengembang Kotlin mengklaim bahwa file sumber dari folder pertama (
*.kt
) harus dikompilasi lebih awal dari file sumber dari folder kedua (
*.java
) dan karena itu sangat menyarankan Anda mengubah pengaturan target standar Maven:
<build> <plugins> <plugin> <groupId>org.jetbrains.kotlin</groupId> <artifactId>kotlin-maven-plugin</artifactId> <version>${kotlin.version}</version> <executions> <execution> <id>compile</id> <phase>process-sources</phase> <goals> <goal>compile</goal> </goals> <configuration> <sourceDirs> <sourceDir>${project.basedir}/src/main/kotlin</sourceDir> <sourceDir>${project.basedir}/src/main/java</sourceDir> </sourceDirs> </configuration> </execution> <execution> <id>test-compile</id> <goals> <goal>test-compile</goal> </goals> <configuration> <sourceDirs> <sourceDir>${project.basedir}/src/test/kotlin</sourceDir> <sourceDir>${project.basedir}/src/test/java</sourceDir> </sourceDirs> </configuration> </execution> </executions> </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.5.1</version> <executions> <execution> <id>default-compile</id> <phase>none</phase> </execution> <execution> <id>default-testCompile</id> <phase>none</phase> </execution> <execution> <id>java-compile</id> <phase>compile</phase> <goals> <goal>compile</goal> </goals> </execution> <execution> <id>java-test-compile</id> <phase>test-compile</phase> <goals> <goal>testCompile</goal> </goals> </execution> </executions> </plugin> </plugins> </build>
Saya tidak bisa mengatakan betapa pentingnya hal ini - proyek berjalan cukup baik tanpa lembar ini. Tapi untuk jaga-jaga, Anda diperingatkan.
Mari kita membuat tiga paket sekaligus (mengapa agak sepele?):
model
- untuk kelas yang menggambarkan objek dari dunia game;game
- untuk kelas yang mengimplementasikan gameplay;ui
- untuk kelas yang bertanggung jawab atas interaksi pengguna.
Yang terakhir hanya akan berisi antarmuka, metode yang akan kita gunakan untuk input dan output data. Kami akan menyimpan implementasi spesifik dalam proyek terpisah, tetapi lebih lanjut tentang itu nanti. Sementara itu, agar tidak menyemprot terlalu banyak, kami akan menambahkan kelas-kelas ini di sini, berdampingan.
Jangan mencoba untuk segera melakukannya dengan sempurna: pikirkan rincian nama paket, antarmuka, kelas dan metode; meresepkan interaksi objek di antara mereka sendiri - semua ini akan berubah, dan lebih dari selusin kali. Ketika proyek berkembang, banyak hal akan tampak jelek, besar, tidak efektif untuk Anda dan sejenisnya - jangan ragu untuk mengubahnya, karena refactoring dalam IDE modern adalah operasi yang sangat murah.
Kami juga akan membuat kelas dengan fungsi
main
dan kami siap untuk pencapaian besar. Anda dapat menggunakan IDE itu sendiri untuk peluncuran, tetapi seperti yang akan Anda lihat nanti, metode ini tidak cocok untuk tujuan kami (konsol IDE standar tidak dapat menampilkan temuan grafis kami sebagaimana mestinya), jadi kami akan mengonfigurasi peluncuran dari luar menggunakan batch (atau shell pada sistem UNIX) file. Tetapi sebelum itu, kami akan membuat beberapa pengaturan tambahan.
Setelah operasi
mvn package
selesai, kami mendapatkan output dari arsip JAR dengan semua kelas yang dikompilasi. Pertama, secara default, arsip ini tidak menyertakan dependensi yang diperlukan agar proyek dapat berfungsi (sejauh ini kami belum memilikinya, tetapi mereka pasti akan muncul di masa mendatang). Kedua, jalur ke kelas utama yang berisi metode
main
tidak ditentukan dalam file manifes arsip, jadi kami tidak akan
java -jar dice-1.0.jar
memulai proyek dengan perintah
java -jar dice-1.0.jar
. Perbaiki ini dengan menambahkan pengaturan tambahan ke
pom.xml
:
<build> <plugins> <plugin> <artifactId>maven-assembly-plugin</artifactId> <version>2.6</version> <executions> <execution> <phase>package</phase> <goals> <goal>single</goal> </goals> </execution> </executions> <configuration> <descriptorRefs> <descriptorRef>jar-with-dependencies</descriptorRef> </descriptorRefs> <archive> <manifest> <mainClass>my.company.dice.MainKt</mainClass> </manifest> </archive> </configuration> </plugin> </plugins> </build>
Perhatikan nama kelas utama. Untuk fungsi Kotlin yang terdapat di luar kelas (seperti fungsi
main
), kelas tetap dibuat selama kompilasi (karena JVM tidak tahu apa-apa dan tidak ingin tahu). Nama kelas ini adalah nama file dengan penambahan
Kt
. Artinya, jika Anda menamai kelas utama
Main
, maka itu akan dikompilasi ke dalam file
MainKt.class
. Ini yang terakhir yang harus kita tunjukkan dalam manifes file jar.
Sekarang, ketika membangun proyek, kita akan mendapatkan dua file jar di output:
dice-1.0.jar
dan
dice-1.0-jar-with-dependencies.jar
. Kami tertarik pada yang kedua. Kami akan menulis skrip peluncuran untuk itu.
dice.bat (untuk Windows)
@ECHO OFF rem Compiling call "path_to_maven\mvn.bat" -f "path_to_project\Dice\pom.xml" package if errorlevel 1 echo Project compilation failed! & pause & goto :EOF rem Running java -jar path_to_project\Dice\target\dice-1.0-jar-with-dependencies.jar pause
dice.sh (untuk UNIX)
Harap perhatikan bahwa jika kompilasi gagal, kami terpaksa menyela naskah. Kalau tidak, bukan harpa terakhir yang akan diluncurkan, tetapi file yang tersisa dari perakitan sukses sebelumnya (kadang-kadang kita bahkan tidak akan menemukan perbedaannya). Seringkali, pengembang menggunakan perintah
mvn clean package
untuk menghapus semua file yang dikompilasi sebelumnya, tetapi dalam kasus ini seluruh proses kompilasi akan selalu dimulai dari awal (bahkan jika kode sumber tidak berubah), yang akan memakan banyak waktu. Tapi kita tidak bisa menunggu - kita perlu membuat game.
Jadi, proyek dimulai dengan baik, tetapi sejauh ini tidak melakukan apa pun. Jangan khawatir, kami akan segera memperbaikinya.
Langkah Enam Benda utama
Secara bertahap, kita akan mulai mengisi paket
model
dengan kelas-kelas yang diperlukan untuk gameplay.
Kubus adalah segalanya bagi kami, tambahkan dulu. Setiap dadu (turunan dari kelas
Die
) ditandai oleh jenis (warna) dan ukurannya. Untuk jenis kubus, kami akan membuat penghitungan terpisah (
Die.Type
), tandai ukuran dengan bilangan bulat dari 4 hingga 12. Kami juga menerapkan metode
roll()
, yang akan menghasilkan angka yang didistribusikan secara acak dan seragam dari kisaran yang tersedia untuk kubus (dari 1 hingga nilai ukuran termasuk).
Kelas mengimplementasikan antarmuka
Comparable
kubus dapat dibandingkan satu sama lain (berguna nanti ketika kita akan menampilkan beberapa kubus dalam baris yang dipesan). Kubus yang lebih besar akan ditempatkan lebih awal.
class Die(val type: Type, val size: Int) : Comparable<Die> { enum class Type { PHYSICAL,
Agar tidak mengumpulkan debu, kubus disimpan di tas tangan (salinan kelas
Bag
). Orang hanya bisa menebak apa yang terjadi di dalam tas, oleh karena itu, tidak masuk akal untuk menggunakan koleksi yang dipesan. Sepertinya begitu. Set (set) mengimplementasikan ide yang kita butuhkan dengan baik, tetapi tidak cocok untuk dua alasan. Pertama, saat menggunakannya, Anda harus menerapkan metode
equals()
dan
hashCode()
, dan tidak jelas bagaimana, karena tidak benar membandingkan jenis dan ukuran kubus - sejumlah kubus identik dapat disimpan di set kami. Kedua, menarik kubus keluar dari tas, kami berharap untuk mendapatkan bukan hanya sesuatu yang non-deterministik, tetapi acak, setiap kali berbeda. Oleh karena itu, saya menyarankan Anda untuk menggunakan koleksi yang diurutkan (daftar) dan mengocoknya setiap kali Anda menambahkan elemen baru (dalam metode
put()
) atau segera sebelum mengeluarkan (dalam metode
draw()
).
Metode
examine()
cocok untuk kasus-kasus ketika seorang pemain yang bosan dengan ketidakpastian mengguncang isi tas di atas meja di hati (perhatikan penyortiran), dan metode
clear()
- jika kubus yang digoyang tidak kembali ke tas.
open class Bag { protected val dice = LinkedList<Die>() val size get() = dice.size fun put(vararg dice: Die) { dice.forEach(this.dice::addLast) this.dice.shuffle() } fun draw(): Die = dice.pollFirst() fun clear() = dice.clear() fun examine() = dice.sorted().toList() }
Selain tas dengan kubus, Anda juga perlu tumpukan dengan kubus (contoh kelas
Pile
). Dari yang pertama, yang kedua berbeda karena isinya terlihat oleh para pemain, dan oleh karena itu, jika perlu, menghapus kubus dari tumpukan, pemain dapat memilih contoh tertentu yang menarik. Kami menerapkan ide ini menggunakan metode
removeDie()
.
class Pile : Bag() { fun removeDie(die: Die) = dice.remove(die) }
Sekarang kita beralih ke karakter utama kita - pahlawan. Artinya, karakter yang sekarang kita sebut pahlawan (ada alasan bagus untuk tidak memanggil kelas Anda dengan nama
Character
in Java). Ada berbagai jenis karakter (untuk mengatakan kelas, meskipun
class
kata juga lebih baik untuk tidak menggunakannya), tetapi untuk prototipe kerja kami, kami hanya akan mengambil dua:
Brawler (yaitu, Fighter dengan penekanan pada kekuatan dan kekuatan) dan
Hunter (alias Ranger / Thief, dengan penekanan ketangkasan dan siluman). Kelas pahlawan menentukan karakteristik, keterampilan, dan set awal kubusnya, tetapi seperti yang akan dilihat kemudian, para pahlawan tidak akan terikat secara ketat ke kelas, dan karenanya pengaturan pribadi mereka dapat dengan mudah diubah di satu tempat.
Kami akan menambahkan properti yang diperlukan untuk pahlawan sesuai dengan dokumen desain: nama, jenis kubus favorit, batas kubus, keterampilan yang dipelajari dan tidak dipelajari, tangan, tas dan tumpukan untuk diatur ulang. Perhatikan fitur-fitur implementasi properti koleksi. Di seluruh dunia yang beradab, dianggap bentuk yang buruk untuk menyediakan akses keluar (dengan bantuan pengambil) ke koleksi yang disimpan di dalam objek - programmer yang tidak bertanggung jawab akan dapat mengubah isi koleksi ini tanpa sepengetahuan kelas. Salah satu cara untuk mengatasinya adalah dengan menerapkan metode terpisah untuk menambah dan menghapus elemen, mendapatkan jumlah mereka dan mengakses dengan indeks. Anda dapat menerapkan rajin rajin, tetapi pada saat yang sama mengembalikan bukan koleksi itu sendiri, tetapi salinan abadi - untuk sejumlah kecil elemen itu tidak terlalu menakutkan untuk melakukan hal itu.
data class Hero(val type: Type) { enum class Type { BRAWLER HUNTER } var name = "" var isAlive = true var favoredDieType: Die.Type = Die.Type.ALLY val hand = Hand(0) val bag: Bag = Bag() val discardPile: Pile = Pile() private val diceLimits = mutableListOf<DiceLimit>() private val skills = mutableListOf<Skill>() private val dormantSkills = mutableListOf<Skill>() fun addDiceLimit(limit: DiceLimit) = diceLimits.add(limit) fun getDiceLimits(): List<DiceLimit> = Collections.unmodifiableList(diceLimits) fun addSkill(skill: Skill) = skills.add(skill) fun getSkills(): List<Skill> = Collections.unmodifiableList(skills) fun addDormantSkill(skill: Skill) = dormantSkills.add(skill) fun getDormantSkills(): List<Skill> = Collections.unmodifiableList(dormantSkills) fun increaseDiceLimit(type: Die.Type) { diceLimits.find { it.type == type }?.let { when { it.current < it.maximal -> it.current++ else -> throw IllegalArgumentException("Already at maximum") } } ?: throw IllegalArgumentException("Incorrect type specified") } fun hideDieFromHand(die: Die) { bag.put(die) hand.removeDie(die) } fun discardDieFromHand(die: Die) { discardPile.put(die) hand.removeDie(die) } fun hasSkill(type: Skill.Type) = skills.any { it.type == type } fun improveSkill(type: Skill.Type) { dormantSkills .find { it.type == type } ?.let { skills.add(it) dormantSkills.remove(it) } skills .find { it.type == type } ?.let { when { it.level < it.maxLevel -> it.level += 1 else -> throw IllegalStateException("Skill already maxed out") } } ?: throw IllegalArgumentException("Skill not found") } }
Tangan pahlawan (kubus yang dia miliki saat ini) dijelaskan oleh objek yang terpisah (kelas
Hand
). Keputusan desain untuk menjaga kubus sekutu terpisah dari lengan utama adalah salah satu yang pertama kali terlintas dalam pikiran. Pada awalnya itu tampak seperti fitur yang sangat keren, tetapi kemudian menghasilkan sejumlah besar masalah dan ketidaknyamanan. Namun demikian, kami tidak mencari cara yang mudah, dan oleh karena itu daftar
dice
dan
allies
siap melayani kami, dengan semua metode yang Anda butuhkan untuk menambah, menerima, dan menghapus (beberapa dari mereka secara cerdas menentukan mana dari dua daftar yang akan diakses). Ketika Anda menghapus sebuah kubus dari tangan Anda, semua kubus berikutnya akan pindah ke bagian atas daftar, mengisi kekosongan - di masa depan ini akan sangat memudahkan pencarian (tidak perlu menangani situasi dengan
null
).
class Hand(var capacity: Int) { private val dice = LinkedList<Die>() private val allies = LinkedList<Die>() val dieCount get() = dice.size val allyDieCount get() = allies.size fun dieAt(index: Int) = when { (index in 0 until dieCount) -> dice[index] else -> null } fun allyDieAt(index: Int) = when { (index in 0 until allyDieCount) -> allies[index] else -> null } fun addDie(die: Die) = when { die.type == Die.Type.ALLY -> allies.addLast(die) else -> dice.addLast(die) } fun removeDie(die: Die) = when { die.type == Die.Type.ALLY -> allies.remove(die) else -> dice.remove(die) } fun findDieOfType(type: Die.Type): Die? = when (type) { Die.Type.ALLY -> if (allies.isNotEmpty()) allies.first else null else -> dice.firstOrNull { it.type == type } } fun examine(): List<Die> = (dice + allies).sorted() }
Koleksi benda-benda dari kelas
DiceLimit
menetapkan batas jumlah kubus dari setiap jenis yang dapat dimiliki pahlawan di awal skrip. Tidak ada yang istimewa untuk dikatakan, kami menentukan pada awalnya, nilai maksimum dan saat ini untuk setiap jenis.
class DiceLimit(val type: Die.Type, val initial: Int, val maximal: Int, var current: Int)
Tetapi dengan keterampilan itu lebih menarik. Masing-masing harus diimplementasikan secara individual (sekitar nanti), tetapi kami hanya akan mempertimbangkan dua:
Hit dan
Tembak (masing-masing satu untuk setiap kelas). Keterampilan dapat dikembangkan ("dipompa") dari level awal ke level maksimum, yang sering memengaruhi pengubah yang ditambahkan ke gulungan dadu. Ini
maxLevel
pada
level
properti,
maxLevel
,
modifier1
dan
modifier2
.
class Skill(val type: Type) { enum class Type {
Perhatikan metode tambahan dari kelas
Hero
, yang memungkinkan Anda untuk menyembunyikan atau melempar dadu dari tangan Anda, memeriksa apakah pahlawan memiliki keterampilan tertentu, dan juga meningkatkan tingkat keterampilan yang dipelajari atau mempelajari yang baru. Semuanya akan dibutuhkan cepat atau lambat, tetapi sekarang kita tidak akan membahasnya secara terperinci.
Tolong jangan takut dengan jumlah kelas yang harus kita buat. Untuk proyek dengan kompleksitas ini, beberapa ratus adalah hal yang umum. Di sini, seperti dalam pekerjaan serius - kita mulai dari kecil, secara bertahap meningkatkan kecepatan, dalam sebulan kita takut dengan ruang lingkup. Jangan lupa, kami masih merupakan studio kecil untuk satu orang - kami tidak dihadapkan dengan tugas-tugas besar.
“Sesuatu membuatku muak. Aku akan merokok atau apalah ... "Dan kami akan melanjutkan.
Para pahlawan dan kemampuan mereka dijelaskan, sekarang saatnya untuk beralih ke kekuatan lawan - Mekanika Game yang hebat dan mengerikan. Atau lebih tepatnya, benda-benda yang dengannya pahlawan kita harus berinteraksi.
Tiga kubus dan kartu yang gagah berani akan menentang protagonis kita yang gagah berani: penjahat (kelas
Villain
), musuh (kelas
Enemy
) dan rintangan (kelas
Obstacle
), disatukan di bawah istilah umum "ancaman" (
Threat
adalah kelas "terkunci" abstrak, daftar ahli waris yang mungkin ada adalah ketat terbatas). Setiap ancaman memiliki seperangkat fitur khusus (
Trait
) yang menggambarkan aturan perilaku khusus ketika dihadapkan dengan ancaman seperti itu dan menambah variasi pada gameplay.
sealed class Threat { var name: String = "" var description: String = "" private val traits = mutableListOf<Trait>() fun addTrait(trait: Trait) = traits.add(trait) fun getTraits(): List<Trait> = traits } class Obstacle(val tier: Int, vararg val dieTypes: Die.Type) : Threat() class Villain : Threat() class Enemy : Threat() enum class Trait { MODIFIER_PLUS_ONE,
Harap dicatat bahwa daftar objek dari kelas
Trait
didefinisikan sebagai bisa berubah (
MutableList
), tetapi diberikan sebagai antarmuka
List
tidak dapat diubah. Walaupun ini akan bekerja di Kotlin, pendekatannya tidak aman, namun, karena tidak ada yang menghentikannya dari mengubah daftar yang dihasilkan menjadi antarmuka yang bisa diubah dan membuat berbagai modifikasi - sangat mudah untuk melakukan ini jika Anda mengakses kelas dari kode Java (di mana antarmuka
List
bisa berubah-ubah). Cara paling paranoid untuk melindungi koleksi Anda adalah dengan melakukan sesuatu seperti ini:
fun getTraits(): List<Trait> = Collections.unmodifiableList(traits)
tetapi kami tidak akan begitu teliti dalam mendekati masalah ini (Anda, bagaimanapun, diperingatkan).
Karena kekhasan mekanika game, kelas
Obstacle
berbeda dari rekan-rekannya di hadapan bidang tambahan, tetapi kami tidak akan fokus pada mereka.
Kartu ancaman (dan jika Anda dengan cermat membaca dokumen desain, maka ingatlah bahwa ini adalah kartu) digabungkan ke dalam deck yang diwakili oleh kelas
Deck
:
class Deck<E: Threat> { private val cards = LinkedList<E>() val size get() = cards.size fun addToTop(card: E) = cards.addFirst(card) fun addToBottom(card: E) = cards.addLast(card) fun revealTop(): E = cards.first fun drawFromTop(): E = cards.removeFirst() fun shuffle() = cards.shuffle() fun clear() = cards.clear() fun examine() = cards.toList() }
Tidak ada yang aneh di sini, kecuali bahwa kelas parameter dan berisi daftar yang dipesan (atau lebih tepatnya antrian dua arah), yang dapat dicampur menggunakan metode yang sesuai. Setumpuk musuh dan rintangan akan dibutuhkan untuk kita secara harfiah dalam sedetik, ketika kita sampai pada pertimbangan ...
... dari kelas
Location
, yang setiap contohnya menggambarkan lokalitas unik yang harus dikunjungi pahlawan kita sebagai bagian dari naskah.
class Location { var name: String = "" var description: String = "" var isOpen = true var closingDifficulty = 0 lateinit var bag: Bag var villain: Villain? = null lateinit var enemies: Deck<Enemy> lateinit var obstacles: Deck<Obstacle> private val specialRules = mutableListOf<SpecialRule>() fun addSpecialRule(rule: SpecialRule) = specialRules.add(rule) fun getSpecialRules() = specialRules }
Setiap lokalitas memiliki nama, deskripsi, kesulitan penutupan dan tanda "buka / tutup". Di suatu tempat di sini penjahat mungkin bersembunyi (atau mungkin tidak bersembunyi, sebagai akibatnya properti
villain
mungkin
null
). Di setiap daerah ada tas dengan kubus dan setumpuk kartu dengan ancaman. Selain itu, area tersebut dapat memiliki fitur gim uniknya sendiri (
SpecialRule
), yang, seperti properti ancaman, menambah variasi gameplay. Seperti yang Anda lihat, kami meletakkan fondasi untuk fungsionalitas di masa mendatang, bahkan jika kami tidak berencana untuk mengimplementasikannya dalam waktu dekat (yang, pada kenyataannya, kami membutuhkan tahap pemodelan).
Akhirnya, tetap menerapkan skrip (kelas
Scenario
):
class Scenario { var name = "" var description = "" var level = 0 var initialTimer = 0 private val allySkills = mutableListOf<AllySkill>() private val specialRules = mutableListOf<SpecialRule>() fun addAllySkill(skill: AllySkill) = allySkills.add(skill) fun getAllySkills(): List<AllySkill> = Collections.unmodifiableList(allySkills) fun addSpecialRule(rule: SpecialRule) = specialRules.add(rule) fun getSpecialRules(): List<SpecialRule> = Collections.unmodifiableList(specialRules) }
Setiap skenario ditandai oleh level dan nilai awal dari timer. Mirip dengan apa yang dilihat sebelumnya, aturan khusus (Aturan khusus) dan keterampilan sekutu ditetapkan (kita akan kehilangan pertimbangan). Anda mungkin berpikir bahwa skrip juga harus berisi daftar lokasi (objek dari kelas
Location
) dan, secara logis, ini benar-benar demikian. Tetapi seperti yang akan dilihat nanti, kami tidak akan menggunakan koneksi seperti itu di mana pun dan itu tidak memberikan keuntungan teknis apa pun.
Saya mengingatkan Anda bahwa semua kelas yang dipertimbangkan sejauh ini terkandung dalam paket
model
- kami, sebagai seorang anak, untuk mengantisipasi pertempuran mainan epik, menempatkan tentara di permukaan meja.
Dan sekarang, setelah beberapa saat yang menyakitkan, atas sinyal dari Panglima Tertinggi, kita akan bergegas ke pertempuran, mendorong mainan kita bersama dan menikmati konsekuensi dari gameplay. Namun sebelum itu, sedikit tentang penataan itu sendiri."Baiklah ..."Langkah ketujuh. Pola dan Generator
Mari kita bayangkan sejenak proses menghasilkan objek apa pun yang sebelumnya dianggap akan, misalnya, lokasi (medan). Kita perlu membuat instance kelas Location
, menginisialisasi bidangnya dengan nilai, dan untuk setiap lokalitas yang ingin kita gunakan dalam game. Tapi tunggu: setiap lokasi harus memiliki tas, yang juga perlu dibuat. Dan tas memiliki kubus - ini juga contoh dari kelas yang sesuai ( Die
). Ini saya tidak berbicara tentang musuh dan rintangan - mereka umumnya perlu dikumpulkan di geladak. Dan penjahat tidak menentukan medan itu sendiri, tetapi fitur skenario terletak satu tingkat lebih tinggi. Nah, Anda mengerti intinya. Kode sumber untuk yang di atas mungkin terlihat seperti ini: val location = Location().apply { name = "Some location" description = "Some description" isOpen = true closingDifficulty = 4 bag = Bag().apply { put(Die(Die.Type.PHYSICAL, 4)) put(Die(Die.Type.SOMATIC, 4)) put(Die(Die.Type.MENTAL, 4)) put(Die(Die.Type.ENEMY, 6)) put(Die(Die.Type.OBSTACLE, 6)) put(Die(Die.Type.VILLAIN, 6)) } villain = Villain().apply { name = "Some villain" description = "Some description" addTrait(Trait.MODIFIER_PLUS_ONE) } enemies = Deck<Enemy>().apply { addToTop(Enemy().apply { name = "Some enemy" description = "Some description" }) addToTop(Enemy().apply { name = "Other enemy" description = "Some description" }) shuffle() } obstacles = Deck<Obstacle>().apply { addToTop(Obstacle(1, Die.Type.PHYSICAL, Die.Type.VERBAL).apply { name = "Some obstacle" description = "Some Description" }) } }
Ini juga berkat bahasa dan desain Kotlin apply{}
- di Jawa, kodenya akan dua kali lebih besar. Selain itu, akan ada banyak tempat, seperti yang kami katakan, dan selain itu ada juga skenario, petualangan dan pahlawan dengan keterampilan dan karakteristik mereka - secara umum, ada sesuatu yang harus dilakukan oleh perancang gim.Tetapi perancang gim tidak akan menulis kode, dan tidak nyaman bagi kami untuk mengkompilasi ulang proyek pada perubahan sekecil apa pun di dunia gim. Di sini, setiap programmer yang kompeten akan keberatan bahwa deskripsi objek dari kode kelas harus dipisahkan - idealnya, sehingga contoh yang terakhir dihasilkan secara dinamis berdasarkan yang diperlukan, mirip dengan bagaimana bagian dibuat dari pabrik gambar. Kami juga menerapkan gambar-gambar tersebut, kami hanya menyebutnya gambar templat dan menggambarkannya sebagai contoh kelas khusus. Memiliki pola seperti itu, kode program khusus (generator) akan membuat objek akhir dari model yang dijelaskan sebelumnya.Proses menghasilkan objek dari templat Dengan demikian, untuk setiap kelas objek kita, dua entitas baru harus didefinisikan: antarmuka templat dan kelas generator. Dan karena jumlah objek yang layak telah terakumulasi, maka juga akan ada sejumlah entitas ... tidak senonoh:Silakan bernafas lebih dalam, dengarkan baik-baik dan jangan terganggu. Pertama, diagram tidak menunjukkan semua objek dari dunia game, tetapi hanya yang utama, yang tidak dapat Anda lakukan tanpa terlebih dahulu. Kedua, agar tidak membebani sirkuit dengan detail yang tidak perlu, beberapa koneksi yang telah disebutkan sebelumnya dalam diagram lain dihilangkan.Mari kita mulai dengan sesuatu yang sederhana - menghasilkan kubus. "Bagaimana? - katamu. - Apakah kita tidak cukup konstruktor? Ya, itu yang dengan tipe dan ukuran. " Tidak, saya akan menjawab, tidak cukup. Memang, dalam banyak kasus (baca aturan), kubus harus dihasilkan secara sewenang-wenang dalam jumlah sewenang-wenang (misalnya: "dari satu hingga tiga kubus baik biru atau hijau"). Selain itu, ukurannya harus dipilih tergantung pada tingkat kerumitan skrip. Oleh karena itu, kami memperkenalkan antarmuka khusus DieTypeFilter
. interface DieTypeFilter { fun test(type: Die.Type): Boolean }
Implementasi yang berbeda dari antarmuka ini akan memeriksa apakah tipe kubus sesuai dengan set aturan yang berbeda (yang hanya ada dalam pikiran). Misalnya, apakah tipe tersebut sesuai dengan nilai yang ditentukan secara ketat ("biru") atau rentang nilai ("biru, kuning atau hijau"); atau, sebaliknya, berkorespondensi dengan jenis apa pun selain yang diberikan ("jika saja tidak putih dalam hal apa pun" - apa pun, hanya saja bukan itu). Bahkan jika tidak jelas sebelumnya implementasi spesifik apa yang dibutuhkan, itu tidak masalah - mereka dapat ditambahkan kemudian, sistem tidak akan melepaskan diri dari ini (polimorfisme, ingat?). class SingleDieTypeFilter(val type: Die.Type): DieTypeFilter { override fun test(type: Die.Type) = (this.type == type) } class InvertedSingleDieTypeFilter(val type: Die.Type): DieTypeFilter { override fun test(type: Die.Type) = (this.type != type) } class MultipleDieTypeFilter(vararg val types: Die.Type): DieTypeFilter { override fun test(type: Die.Type) = (type in types) } class InvertedMultipleDieTypeFilter(vararg val types: Die.Type): DieTypeFilter { override fun test(type: Die.Type) = (type !in types) }
Ukuran kubus juga akan ditetapkan secara sewenang-wenang, tetapi lebih dari itu nanti. Sementara itu, kita akan menulis generator kubus ( DieGenerator
), yang, tidak seperti konstruktor kelas Die
, tidak akan menerima jenis dan ukuran kubus yang eksplisit, tetapi filter dan tingkat kerumitannya. private val DISTRIBUTION_LEVEL1 = intArrayOf(4, 4, 4, 4, 6, 6, 6, 6, 8) private val DISTRIBUTION_LEVEL2 = intArrayOf(4, 6, 6, 6, 6, 8, 8, 8, 8, 10) private val DISTRIBUTION_LEVEL3 = intArrayOf(6, 8, 8, 8, 10, 10, 10, 10, 12, 12, 12) private val DISTRIBUTIONS = arrayOf( intArrayOf(4), DISTRIBUTION_LEVEL1, DISTRIBUTION_LEVEL2, DISTRIBUTION_LEVEL3 ) fun getMaxLevel() = DISTRIBUTIONS.size - 1 fun generateDie(filter: DieTypeFilter, level: Int) = Die(generateDieType(filter), generateDieSize(level)) private fun generateDieType(filter: DieTypeFilter): Die.Type { var type: Die.Type do { type = Die.Type.values().random() } while (!filter.test(type)) return type } private fun generateDieSize(level: Int) = DISTRIBUTIONS[if (level < 1 || level > getMaxLevel()) 0 else level].random()
Di Jawa, metode ini akan statis, tetapi karena kita berurusan dengan Kotlin, kita tidak perlu kelas seperti itu, yang juga berlaku untuk generator lain yang dibahas di bawah ini (namun, pada tingkat logis, kita masih akan menggunakan konsep kelas).Dua metode pribadi secara terpisah menghasilkan jenis dan ukuran kubus - sesuatu yang menarik dapat dikatakan tentang masing-masing. Metode generateDieType()
ini dapat didorong ke loop infinite dengan melewatkan filter input override fun test(filter: DieTypeFilter) = false
(para penulis memiliki keyakinan yang kuat bahwa seseorang dapat keluar dari ketidakkonsistenan logis dan plot lubang jika karakter itu sendiri mengarahkan mereka kepada audiens selama cerita). Metode ini generateDieSize()
menghasilkan ukuran pseudo-acak berdasarkan distribusi yang ditentukan dalam bentuk array (satu untuk setiap level). Ketika di usia tua saya menjadi kaya dan membeli sebungkus kubus bermain multi-warna, saya tidak akan bisa bermain Dice , karena saya tidak akan tahu cara mengumpulkan tas secara acak dari mereka (kecuali meminta tetangga dan berpaling pada saat itu). Ini bukan setumpuk kartu yang dapat dikocok terbalik, ini membutuhkan mekanisme dan perangkat khusus. Jika seseorang memiliki ide (dan dia memiliki kesabaran untuk membaca ke tempat ini), silakan berbagi komentar.Dan karena kita berbicara tentang tas, kita akan mengembangkan templat untuk mereka. Tidak seperti teman Anda, templat ini ( BagTemplate
) akan menjadi kelas tertentu. Ini berisi template lain - masing-masing menggambarkan aturan (atau Plan
) dimana satu atau lebih kubus (ingat persyaratan yang dibuat sebelumnya?) Apakah ditambahkan ke tas. class BagTemplate { class Plan(val minQuantity: Int, val maxQuantity: Int, val filter: DieTypeFilter) val plans = mutableListOf<Plan>() fun addPlan(minQuantity: Int, maxQuantity: Int, filter: DieTypeFilter) { plans.add(Plan(minQuantity, maxQuantity, filter)) } }
Setiap rencana mendefinisikan pola untuk jenis kubus, serta jumlah (minimum dan maksimum) kubus yang memenuhi pola ini. Berkat pendekatan ini, Anda dapat membuat tas sesuai aturan yang aneh (dan saya sekali lagi menangis dengan sedih karena usia tua, karena tetangga saya dengan tegas menolak untuk membantu saya). Sesuatu seperti ini: private fun realizePlan(plan: BagTemplate.Plan, level: Int): Array<Die> { val count = (plan.minQuantity..plan.maxQuantity).shuffled().last() return (1..count).map { generateDie(plan.filter, level) }.toTypedArray() } fun generateBag(template: BagTemplate, level: Int): Bag { return template.plans.asSequence() .map { realizePlan(it, level) } .fold(Bag()) { b, d -> b.put(*d); b } } }
Jika Anda, sama seperti saya, bosan dengan semua fungsionalisme ini, kencangkan diri Anda - itu hanya akan menjadi lebih buruk. Tapi kemudian, tidak seperti banyak tutorial tidak jelas di Internet, kami memiliki kesempatan untuk mempelajari penggunaan berbagai metode pintar dalam kaitannya dengan area subjek yang nyata dan dapat dipahami.Sendiri, tas tidak akan tergeletak di lapangan - Anda harus memberikannya kepada para pahlawan dan lokasi. Mari kita mulai dengan yang terakhir. interface LocationTemplate { val name: String val description: String val bagTemplate: BagTemplate val basicClosingDifficulty: Int val enemyCardsCount: Int val obstacleCardsCount: Int val enemyCardPool: Collection<EnemyTemplate> val obstacleCardPool: Collection<ObstacleTemplate> val specialRules: List<SpecialRule> }
Dalam bahasa Kotlin, alih-alih metode, get()
Anda bisa menggunakan properti antarmuka - ini jauh lebih ringkas. Kami sudah terbiasa dengan template tas, pertimbangkan metode yang tersisa. Properti basicClosingDifficulty
akan menetapkan kompleksitas dasar pemeriksaan untuk menutup medan. Kata "dasar" di sini hanya berarti bahwa kompleksitas akhir akan tergantung pada tingkat skenario dan tidak jelas pada tahap ini. Selain itu, kita perlu mendefinisikan pola untuk musuh dan rintangan (dan penjahat pada saat yang sama). Selain itu, dari berbagai musuh dan rintangan yang dijelaskan dalam templat, tidak semua akan digunakan, tetapi hanya sejumlah terbatas (untuk meningkatkan nilai replay). Harap perhatikan bahwa aturan khusus ( SpecialRule
) area diterapkan oleh penghitungan sederhana ( enum class
), dan karenanya tidak memerlukan templat terpisah. interface EnemyTemplate { val name: String val description: String val traits: List<Trait> } interface ObstacleTemplate { val name: String val description: String val tier: Int val dieTypes: Array<Die.Type> val traits: List<Trait> } interface VillainTemplate { val name: String val description: String val traits: List<Trait> }
Dan biarkan generator membuat tidak hanya objek individual, tetapi juga seluruh deck bersamanya. fun generateVillain(template: VillainTemplate) = Villain().apply { name = template.name description = template.description template.traits.forEach { addTrait(it) } } fun generateEnemy(template: EnemyTemplate) = Enemy().apply { name = template.name description = template.description template.traits.forEach { addTrait(it) } } fun generateObstacle(template: ObstacleTemplate) = Obstacle(template.tier, *template.dieTypes).apply { name = template.name description = template.description template.traits.forEach { addTrait(it) } } fun generateEnemyDeck(types: Collection<EnemyTemplate>, limit: Int?): Deck<Enemy> { val deck = types .map { generateEnemy(it) } .shuffled() .fold(Deck<Enemy>()) { d, c -> d.addToTop(c); d } limit?.let { while (deck.size > it) deck.drawFromTop() } return deck } fun generateObstacleDeck(templates: Collection<ObstacleTemplate>, limit: Int?): Deck<Obstacle> { val deck = templates .map { generateObstacle(it) } .shuffled() .fold(Deck<Obstacle>()) { d, c -> d.addToTop(c); d } limit?.let { while (deck.size > it) deck.drawFromTop() } return deck }
Jika ada lebih banyak kartu di dek daripada yang kita butuhkan (parameter limit
), kita akan menghapusnya dari sana. Mampu menghasilkan tas dengan kubus dan paket kartu, akhirnya kami dapat membuat medan: fun generateLocation(template: LocationTemplate, level: Int) = Location().apply { name = template.name description = template.description bag = generateBag(template.bagTemplate, level) closingDifficulty = template.basicClosingDifficulty + level * 2 enemies = generateEnemyDeck(template.enemyCardPool, template.enemyCardsCount) obstacles = generateObstacleDeck(template.obstacleCardPool, template.obstacleCardsCount) template.specialRules.forEach { addSpecialRule(it) } }
Medan yang kami tentukan secara eksplisit dalam kode di awal bab sekarang akan mengambil tampilan yang sama sekali berbeda: class SomeLocationTemplate: LocationTemplate { override val name = "Some location" override val description = "Some description" override val bagTemplate = BagTemplate().apply { addPlan(1, 1, SingleDieTypeFilter(Die.Type.PHYSICAL)) addPlan(1, 1, SingleDieTypeFilter(Die.Type.SOMATIC)) addPlan(1, 2, SingleDieTypeFilter(Die.Type.MENTAL)) addPlan(2, 2, MultipleDieTypeFilter(Die.Type.ENEMY, Die.Type.OBSTACLE)) } override val basicClosingDifficulty = 2 override val enemyCardsCount = 2 override val obstacleCardsCount = 1 override val enemyCardPool = listOf( SomeEnemyTemplate(), OtherEnemyTemplate() ) override val obstacleCardPool = listOf( SomeObstacleTemplate() ) override val specialRules = emptyList<SpecialRule>() } class SomeEnemyTemplate: EnemyTemplate { override val name = "Some enemy" override val description = "Some description" override val traits = emptyList<Trait>() } class OtherEnemyTemplate: EnemyTemplate { override val name = "Other enemy" override val description = "Some description" override val traits = emptyList<Trait>() } class SomeObstacleTemplate: ObstacleTemplate { override val name = "Some obstacle" override val description = "Some description" override val traits = emptyList<Trait>() override val tier = 1 override val dieTypes = arrayOf( Die.Type.PHYSICAL, Die.Type.VERBAL ) } val location = generateLocation(SomeLocationTemplate(), 1)
Pembuatan skenario akan terjadi dengan cara yang sama. interface ScenarioTemplate { val name: String val description: String val initialTimer: Int val staticLocations: List<LocationTemplate> val dynamicLocationsPool: List<LocationTemplate> val villains: List<VillainTemplate> val specialRules: List<SpecialRule> fun calculateDynamicLocationsCount(numberOfHeroes: Int) = numberOfHeroes + 2 }
Sesuai dengan aturan, jumlah lokasi yang dihasilkan secara dinamis tergantung pada jumlah pahlawan. Antarmuka mendefinisikan fungsi perhitungan standar, yang, jika diinginkan, dapat didefinisikan ulang dalam implementasi tertentu. Sehubungan dengan persyaratan ini, generator skenario juga akan menghasilkan medan untuk skenario ini - di tempat yang sama penjahat akan didistribusikan secara acak di antara daerah-daerah. fun generateScenario(template: ScenarioTemplate, level: Int) = Scenario().apply { name =template.name description = template.description this.level = level initialTimer = template.initialTimer template.specialRules.forEach { addSpecialRule(it) } } fun generateLocations(template: ScenarioTemplate, level: Int, numberOfHeroes: Int): List<Location> { val locations = template.staticLocations.map { generateLocation(it, level) } + template.dynamicLocationsPool .map { generateLocation(it, level) } .shuffled() .take(template.calculateDynamicLocationsCount(numberOfHeroes)) val villains = template.villains .map(::generateVillain) .shuffled() locations.forEachIndexed { index, location -> if (index < villains.size) { location.villain = villains[index] location.bag.put(generateDie(SingleDieTypeFilter(Die.Type.VILLAIN), level)) } } return locations }
Banyak pembaca yang penuh perhatian akan keberatan bahwa templat perlu disimpan bukan dalam kode sumber kelas, tetapi dalam beberapa file teks (skrip) sehingga bahkan mereka yang jauh dari pemrograman dapat membuat dan memeliharanya. Saya setuju, saya melepaskan topiku, tetapi saya tidak menaburkan abu di kepala saya - karena yang satu tidak mengganggu yang lain. Jika Anda mau, cukup tentukan implementasi khusus templat, nilai properti yang akan diambil dari file eksternal. Proses pembuatan tidak akan mengubah sedikit pun dari ini.Yah, sepertinya mereka belum melupakan apa pun ... Oh ya, pahlawan - mereka juga harus dihasilkan, yang berarti mereka juga memerlukan templat mereka sendiri. Berikut ini beberapa contohnya: interface HeroTemplate { val type: Hero.Type val initialHandCapacity: Int val favoredDieType: Die.Type val initialDice: Collection<Die> val initialSkills: List<SkillTemplate> val dormantSkills: List<SkillTemplate> fun getDiceCount(type: Die.Type): Pair<Int, Int>? }
Dan kami segera melihat dua keanehan. Pertama, kami tidak menggunakan templat untuk menghasilkan tas dan kubus di dalamnya. Mengapa
Ya, karena untuk setiap jenis (kelas) pahlawan, daftar kubus awal didefinisikan secara ketat - tidak masuk akal untuk mempersulit proses pembuatannya. Kedua, getDiceCount()
- ampas macam apa ini ??? Tenang, ini adalah orang-orang DiceLimit
yang menentukan batasan pada kubus. Dan template untuk mereka dipilih dalam bentuk yang aneh sehingga nilai-nilai spesifik dicatat lebih jelas. Lihat sendiri dari contoh: class BrawlerHeroTemplate : HeroTemplate { override val type = Hero.Type.BRAWLER override val favoredDieType = PHYSICAL override val initialHandCapacity = 4 override val initialDice = listOf( Die(PHYSICAL, 6), Die(PHYSICAL, 6), Die(PHYSICAL, 4), Die(PHYSICAL, 4), Die(PHYSICAL, 4), Die(PHYSICAL, 4), Die(PHYSICAL, 4), Die(PHYSICAL, 4), Die(SOMATIC, 6), Die(SOMATIC, 4), Die(SOMATIC, 4), Die(SOMATIC, 4), Die(MENTAL, 4), Die(VERBAL, 4), Die(VERBAL, 4) ) override fun getDiceCount(type: Die.Type) = when (type) { PHYSICAL -> 8 to 12 SOMATIC -> 4 to 7 MENTAL -> 1 to 2 VERBAL -> 2 to 4 else -> null } override val initialSkills = listOf( HitSkillTemplate() ) override val dormantSkills = listOf<SkillTemplate>() } class HunterHeroTemplate : HeroTemplate { override val type = Hero.Type.HUNTER override val favoredDieType = SOMATIC override val initialHandCapacity = 5 override val initialDice = listOf( Die(PHYSICAL, 4), Die(PHYSICAL, 4), Die(PHYSICAL, 4), Die(SOMATIC, 6), Die(SOMATIC, 6), Die(SOMATIC, 4), Die(SOMATIC, 4), Die(SOMATIC, 4), Die(SOMATIC, 4), Die(SOMATIC, 4), Die(MENTAL, 6), Die(MENTAL, 4), Die(MENTAL, 4), Die(MENTAL, 4), Die(VERBAL, 4) ) override fun getDiceCount(type: Die.Type) = when (type) { PHYSICAL -> 3 to 5 SOMATIC -> 7 to 11 MENTAL -> 4 to 7 VERBAL -> 1 to 2 else -> null } override val initialSkills = listOf( ShootSkillTemplate() ) override val dormantSkills = listOf<SkillTemplate>() }
Tetapi sebelum menulis generator, kami mendefinisikan templat untuk keterampilan. interface SkillTemplate { val type: Skill.Type val maxLevel: Int val modifier1: Int val modifier2: Int val isActive get() = true } class HitSkillTemplate : SkillTemplate { override val type = Skill.Type.HIT override val maxLevel = 3 override val modifier1 = +1 override val modifier2 = +3 } class ShootSkillTemplate : SkillTemplate { override val type = Skill.Type.SHOOT override val maxLevel = 3 override val modifier1 = +0 override val modifier2 = +2 }
Sayangnya, kami tidak akan berhasil dalam keterampilan memukau dalam batch dengan cara yang sama seperti musuh dan skrip. Setiap keterampilan baru membutuhkan perluasan mekanika game, menambahkan kode baru ke mesin game - bahkan dengan pahlawan dalam hal ini lebih mudah. Mungkin proses ini dapat diabstraksikan, tetapi saya belum menemukan cara. Ya, dan jangan terlalu berusaha, jujur saja. fun generateSkill(template: SkillTemplate, initialLevel: Int = 1): Skill { val skill = Skill(template.type) skill.isActive = template.isActive skill.level = initialLevel skill.maxLevel = template.maxLevel skill.modifier1 = template.modifier1 skill.modifier2 = template.modifier2 return skill } fun generateHero(type: Hero.Type, name: String = ""): Hero { val template = when (type) { BRAWLER -> BrawlerHeroTemplate() HUNTER -> HunterHeroTemplate() } val hero = Hero(type) hero.name = name hero.isAlive = true hero.favoredDieType = template.favoredDieType hero.hand.capacity = template.initialHandCapacity template.initialDice.forEach { hero.bag.put(it) } for ((t, l) in Die.Type.values().map { it to template.getDiceCount(it) }) { l?.let { hero.addDiceLimit(DiceLimit(t, it.first, it.second, it.first)) } } template.initialSkills .map { generateSkill(it) } .forEach { hero.addSkill(it) } template.dormantSkills .map { generateSkill(it, 0) } .forEach { hero.addDormantSkill(it) } return hero }
Hanya beberapa saat yang mencolok. Pertama, metode generasi itu sendiri memilih templat yang diinginkan tergantung pada kelas pahlawan. Kedua, tidak perlu menentukan nama dengan segera (kadang-kadang pada tahap generasi kita belum mengetahuinya). Ketiga, Kotlin membawa sejumlah besar gula sintaksis yang belum pernah terjadi sebelumnya, yang oleh beberapa pengembang disalahgunakan. Dan tidak sedikit malu.Langkah Delapan. Siklus game
Akhirnya, kami sampai pada yang paling menarik - implementasi dari siklus permainan. Secara sederhana, mereka mulai "membuat permainan." Banyak pengembang pemula sering memulai dengan tepat dari tahap ini, selain dari pembuatan game, yang lainnya. Terutama segala macam skema kecil yang tidak berarti untuk digambar, pfff ... Tapi kami tidak akan terburu-buru (masih jauh dari pagi), dan karenanya menjadi model yang sedikit lebih. Ya lagiSeperti yang Anda lihat, fragmen yang diberikan dari siklus permainan adalah urutan besarnya lebih kecil dari apa yang kami sebutkan di atas. Kami hanya akan mempertimbangkan proses mentransfer kursus, menjelajahi daerah (dan kami akan menggambarkan pertemuan dengan hanya dua jenis kubus) dan membuang kubus di akhir belokan. Dan menyelesaikan skenario dengan kekalahan (ya, kami belum akan berhasil memenangkan permainan kami) - tetapi bagaimana Anda suka? Timer akan berkurang setiap belokan, dan setelah selesai, sesuatu harus dilakukan. Misalnya, tampilkan pesan dan akhiri permainan - semuanya seperti yang tertulis dalam aturan. Permainan lain harus diselesaikan pada saat kematian para pahlawan, tetapi tidak ada yang akan membahayakan mereka, oleh karena itu kami akan meninggalkannya. Untuk menang, Anda harus menutup semua area, yang sulit meskipun hanya satu. Karena itu, mari kita tinggalkan momen ini. Tidak masuk akal untuk menyemprot terlalu banyak - penting bagi kita untuk memahami esensi, dan menyelesaikan sisanya nanti, di waktu senggang saya (atau lebih tepatnya, untuk menyelesaikannya,dan Anda - pergi menulis gameimpian Anda ).Jadi, hal pertama yang harus dilakukan adalah memutuskan objek mana yang kita butuhkan.Pahlawan Skrip LokasiKami telah meninjau proses penciptaan mereka - kami tidak akan mengulanginya. Kami hanya mencatat pola medan yang akan kami gunakan dalam contoh kecil kami. class TestLocationTemplate : LocationTemplate { override val name = "Test" override val description = "Some Description" override val basicClosingDifficulty = 0 override val enemyCardsCount = 0 override val obstacleCardsCount = 0 override val bagTemplate = BagTemplate().apply { addPlan(2, 2, SingleDieTypeFilter(Die.Type.PHYSICAL)) addPlan(2, 2, SingleDieTypeFilter(Die.Type.SOMATIC)) addPlan(2, 2, SingleDieTypeFilter(Die.Type.MENTAL)) addPlan(2, 2, SingleDieTypeFilter(Die.Type.VERBAL)) addPlan(2, 2, SingleDieTypeFilter(Die.Type.DIVINE)) } override val enemyCardPool = emptyList<EnemyTemplate>() override val obstacleCardPool = emptyList<ObstacleTemplate>() override val specialRules = emptyList<SpecialRule>() }
Seperti yang Anda lihat, di dalam tas hanya kubus "positif" - biru, hijau, ungu, kuning dan biru. Tidak ada musuh dan rintangan di daerah itu, penjahat dan luka tidak ditemukan. Tidak ada aturan khusus juga - implementasinya sangat sekunder.Tumpukan untuk kubus dipertahankan.Atau tumpukan pencegah. Karena kami menempatkan kubus biru di kantong medan, mereka dapat digunakan sebagai cek dan setelah digunakan, disimpan di tumpukan khusus. Instance dari kelas berguna untuk ini Pile
.Pengubah.Yaitu, nilai-nilai numerik yang perlu ditambahkan atau dikurangi dari hasil die roll. Anda bisa menerapkan pengubah global atau pengubah terpisah untuk setiap kubus. Kami akan memilih opsi kedua (jadi lebih jelas), oleh karena itu kami akan membuat kelas sederhana DiePair
. class DiePair(val die: Die, var modifier: Int = 0)
Lokasi karakter di area tersebut.Dalam cara yang baik, momen ini perlu dilacak menggunakan struktur khusus. Misalnya, peta bentuk Map<Location, List<Hero>>
tempat setiap lokalitas akan berisi daftar pahlawan yang ada di dalamnya (serta metode untuk sebaliknya - menentukan lokalitas tempat pahlawan tertentu berada). Jika Anda memutuskan untuk mengikuti jalur ini, maka jangan lupa untuk menambahkan Location
metode ke kelas implementasi equals()
dan hashCode()
, saya harap, tidak perlu menjelaskan alasannya. Kami tidak akan membuang waktu untuk ini, karena area ini hanya satu dan para pahlawan tidak meninggalkannya di mana pun.Memeriksa tangan pahlawan.Dalam proses permainan, para pahlawan secara konstan harus melalui pemeriksaan (yang dijelaskan di bawah), yaitu, mengambil kubus dari tangan, melemparkannya (menambahkan pengubah), mengumpulkan hasilnya jika ada beberapa kubus (meringkas, mengambil maksimum / minimum, rata-rata, dll), membandingkannya dengan lemparan kubus lain (yang dikeluarkan dari kantong area) dan, tergantung pada hasilnya, lakukan tindakan berikut. Tetapi pertama-tama, perlu dipahami apakah pahlawan pada prinsipnya mampu lulus ujian, yaitu, apakah ia memiliki kubus yang diperlukan di tangannya. Untuk ini, kami menyediakan antarmuka yang sederhana HandFilter
. interface HandFilter { fun test(hand: Hand): Boolean }
implementasi antarmuka diterima di lengan masukan dari pahlawan (objek kelas Hand
) dan kembali true
atau false
, tergantung pada hasil tes. Untuk penggalan permainan kami, kami membutuhkan implementasi tunggal: jika kubus biru, hijau, ungu atau kuning bertemu, kami perlu menentukan apakah tangan pahlawan memiliki kubus dengan warna yang sama. class SingleDieHandFilter(private vararg val types: Die.Type) : HandFilter { override fun test(hand: Hand) = (0 until hand.dieCount).mapNotNull { hand.dieAt(it) }.any { it.type in types } || (Die.Type.ALLY in types && hand.allyDieCount > 0) }
Ya, fungsionalisme lagi.Item aktif / terpilih.Sekarang kami telah memastikan bahwa tangan pahlawan cocok untuk melakukan tes, perlu bagi pemain untuk memilih dari tangan yang memotong (atau kubus) yang dengannya ia akan lulus tes ini. Pertama, Anda perlu menyorot (menyorot) posisi yang sesuai (di mana ada kubus dari tipe yang diinginkan). Kedua, Anda perlu menandai kubus yang dipilih. Untuk kedua persyaratan ini, sebuah kelas cocok HandMask
, yang, pada kenyataannya, berisi satu set bilangan bulat (jumlah posisi yang dipilih) dan metode untuk menambah dan menghapusnya. class HandMask { private val positions = mutableSetOf<Int>() private val allyPositions = mutableSetOf<Int>() val positionCount get() = positions.size val allyPositionCount get() = allyPositions.size fun addPosition(position: Int) = positions.add(position) fun removePosition(position: Int) = positions.remove(position) fun addAllyPosition(position: Int) = allyPositions.add(position) fun removeAllyPosition(position: Int) = allyPositions.remove(position) fun checkPosition(position: Int) = position in positions fun checkAllyPosition(position: Int) = position in allyPositions fun switchPosition(position: Int) { if (!removePosition(position)) { addPosition(position) } } fun switchAllyPosition(position: Int) { if (!removeAllyPosition(position)) { addAllyPosition(position) } } fun clear() { positions.clear() allyPositions.clear() } }
Saya sudah mengatakan bagaimana saya menderita dari ide "cerdik" menyimpan kubus putih di tangan yang terpisah? Karena kebodohan ini, Anda harus berurusan dengan dua set dan menduplikasi masing-masing metode yang disajikan. Jika seseorang memiliki ide tentang bagaimana menyederhanakan implementasi persyaratan ini (misalnya, gunakan satu set, tetapi untuk kubus putih indeks dimulai dengan seratus - atau sesuatu yang sama-sama tidak jelas) - bagikan dalam komentar.By the way, kelas yang serupa perlu diimplementasikan untuk memilih kubus dari heap ( PileMask
), tetapi fungsi ini di luar ruang lingkup contoh ini.Pilihan kubus dari tangan.Tapi itu tidak cukup untuk "menyoroti" posisi yang dapat diterima, penting untuk mengubah "highlight" ini dalam proses memilih kubus. Artinya, jika seorang pemain diharuskan untuk mengambil hanya satu dadu dari tangannya, maka ketika memilih dadu ini, semua posisi lain harus menjadi tidak dapat diakses. Selain itu, pada setiap tahap, perlu untuk mengontrol pemenuhan tujuan pemain - yaitu, untuk memahami apakah kubus yang dipilih cukup untuk lulus satu atau tes lain. Tugas yang sulit seperti itu membutuhkan turunan kompleks dari kelas yang kompleks. abstract class HandMaskRule(val hand: Hand) { abstract fun checkMask(mask: HandMask): Boolean abstract fun isPositionActive(mask: HandMask, position: Int): Boolean abstract fun isAllyPositionActive(mask: HandMask, position: Int): Boolean fun getCheckedDice(mask: HandMask): List<Die> { return ((0 until hand.dieCount).filter(mask::checkPosition).map(hand::dieAt)) .plus((0 until hand.allyDieCount).filter(mask::checkAllyPosition).map(hand::allyDieAt)) .filterNotNull() } }
Logika yang cukup rumit, saya akan mengerti dan memaafkan Anda jika kelas ini tidak dapat dipahami oleh Anda. Dan masih berusaha menjelaskan. Implementasi kelas ini selalu menyimpan referensi ke tangan (objek Hand
) yang dengannya mereka akan berurusan. Setiap metode menerima topeng ( HandMask
), yang mencerminkan keadaan pemilihan saat ini (posisi mana yang dipilih oleh pemain dan mana yang tidak). Metode checkMask()
melaporkan apakah kubus yang dipilih cukup untuk lulus tes. Metode isPositionActive()
mengatakan apakah perlu untuk menyoroti posisi tertentu - apakah mungkin untuk menambahkan kubus di posisi ini ke tes (atau menghapus kubus yang sudah dipilih). Metode isAllyPositionActive()
ini sama untuk dadu putih (ya, saya tahu, saya idiot). Nah dan metode penolonggetCheckedDice()
itu hanya mengembalikan daftar semua kubus dari tangan yang sesuai dengan topeng - ini diperlukan untuk mengambil semuanya sekaligus, melemparkannya di atas meja dan menikmati ketukan lucu, yang dengannya mereka menyebar ke berbagai arah.Kita akan membutuhkan dua realisasi dari kelas abstrak ini (kejutan, kejutan!). Yang pertama mengontrol proses lulus tes ketika memperoleh kubus baru dari jenis tertentu (bukan putih). Seperti yang Anda ingat, sejumlah kubus biru dapat ditambahkan ke cek semacam itu. class StatDieAcquireHandMaskRule(hand: Hand, private val requiredType: Die.Type) : HandMaskRule(hand) { private fun checkedDieCount(mask: HandMask) = (0 until hand.dieCount) .filter(mask::checkPosition) .mapNotNull(hand::dieAt) .count { it.type === requiredType } override fun checkMask(mask: HandMask) = (mask.allyPositionCount == 0 && checkedDieCount(mask) == 1) override fun isPositionActive(mask: HandMask, position: Int) = with(hand.dieAt(position)) { when { mask.checkPosition(position) -> true this == null -> false this.type === Die.Type.DIVINE -> true this.type === requiredType && checkedDieCount(mask) < 1 -> true else -> false } } override fun isAllyPositionActive(mask: HandMask, position: Int) = false }
Implementasi kedua lebih rumit. Dia mengontrol die roll di akhir belokan. Dalam hal ini, dua opsi dimungkinkan. Jika jumlah kubus di tangan melebihi ukuran maksimum yang diijinkan (kapasitas), kita harus membuang semua kubus tambahan ditambah sejumlah kubus tambahan (jika kita mau). Jika ukurannya tidak melebihi, maka Anda tidak dapat mengatur ulang apa pun (atau Anda dapat mengatur ulang, jika diinginkan). Dalam hal apapun dadu abu-abu tidak dapat dibuang. class DiscardExtraDiceHandMaskRule(hand: Hand) : HandMaskRule(hand) { private val minDiceToDiscard = if (hand.dieCount > hand.capacity) min(hand.dieCount - hand.woundCount, hand.dieCount - hand.capacity) else 0 private val maxDiceToDiscard = hand.dieCount - hand.woundCount override fun checkMask(mask: HandMask) = (mask.positionCount in minDiceToDiscard..maxDiceToDiscard) && (mask.allyPositionCount in 0..hand.allyDieCount) override fun isPositionActive(mask: HandMask, position: Int) = when { mask.checkPosition(position) -> true hand.dieAt(position) == null -> false hand.dieAt(position)!!.type == Die.Type.WOUND -> false mask.positionCount < maxDiceToDiscard -> true else -> false } override fun isAllyPositionActive(mask: HandMask, position: Int) = hand.allyDieAt(position) != null }
Nezhdanchik: Hand
sebuah properti tiba-tiba muncul di kelas woundCount
yang tidak ada sebelumnya. Anda dapat menulis sendiri implementasinya, mudah. Berlatih secara bersamaan.Melewati cek.Akhirnya sampai ke mereka. Ketika dadu diambil dari tangan, saatnya untuk melemparkannya. Untuk setiap kubus perlu dipertimbangkan: ukurannya, pengubahnya, hasil lemparannya. Meskipun hanya satu kubus yang dapat dikeluarkan dari tas pada satu waktu, beberapa dadu dapat dipasang menentangnya, mengagregasi hasil gulungan mereka. Secara umum, mari kita abstraksi dari dadu dan mewakili pasukan di medan perang. Di satu sisi, kita memiliki musuh - dia hanya satu, tetapi dia kuat dan ganas. Di sisi lain, lawan memiliki kekuatan yang sama dengannya, tetapi dengan dukungan. Hasil pertempuran akan diputuskan dalam satu pertempuran singkat, pemenangnya hanya satu ...Maaf, terbawa Untuk mensimulasikan pertempuran umum kami, kami menerapkan kelas khusus. class DieBattleCheck(val method: Method, opponent: DiePair? = null) { enum class Method { SUM, AVG_UP, AVG_DOWN, MAX, MIN } private inner class Wrap(val pair: DiePair, var roll: Int) private infix fun DiePair.with(roll: Int) = Wrap(this, roll) private val opponent: Wrap? = opponent?.with(0) private val heroics = ArrayList<Wrap>() var isRolled = false var result: Int? = null val heroPairCount get() = heroics.size fun getOpponentPair() = opponent?.pair fun getOpponentResult() = when { isRolled -> opponent?.roll ?: 0 else -> throw IllegalStateException("Not rolled yet") } fun addHeroPair(pair: DiePair) { if (method == Method.SUM && heroics.size > 0) { pair.modifier = 0 } heroics.add(pair with 0) } fun addHeroPair(die: Die, modifier: Int) = addHeroPair(DiePair(die, modifier)) fun clearHeroPairs() = heroics.clear() fun getHeroPairAt(index: Int) = heroics[index].pair fun getHeroResultAt(index: Int) = when { isRolled -> when { (index in 0 until heroics.size) -> heroics[index].roll else -> 0 } else -> throw IllegalStateException("Not rolled yet") } fun roll() { fun roll(wrap: Wrap) { wrap.roll = wrap.pair.die.roll() } isRolled = true opponent?.let { roll(it) } heroics.forEach { roll(it) } } fun calculateResult() { if (!isRolled) { throw IllegalStateException("Not rolled yet") } val opponentResult = opponent?.let { it.roll + it.pair.modifier } ?: 0 val stats = heroics.map { it.roll + it.pair.modifier } val heroResult = when (method) { DieBattleCheck.Method.SUM -> stats.sum() DieBattleCheck.Method.AVG_UP -> ceil(stats.average()).toInt() DieBattleCheck.Method.AVG_DOWN -> floor(stats.average()).toInt() DieBattleCheck.Method.MAX -> stats.max() ?: 0 DieBattleCheck.Method.MIN -> stats.min() ?: 0 } result = heroResult - opponentResult } }
Karena setiap kubus dapat memiliki pengubah, kami akan menyimpan data dalam objek DiePair
. Sepertinya begitu. Sebenarnya, tidak, karena selain kubus dan pengubahnya, Anda juga perlu menyimpan hasil lemparannya (ingat, meskipun kubus itu sendiri menghasilkan nilai ini, ia tidak menyimpannya di antara propertinya). Oleh karena itu, bungkus masing-masing pasangan dalam pembungkus ( Wrap
). Perhatikan metode infiks with
, hehe.Konstruktor kelas mendefinisikan metode agregasi (turunan enumerasi internal Method
) dan lawan (yang mungkin tidak ada). Daftar kubus pahlawan dibentuk menggunakan metode yang tepat. Ini juga menyediakan banyak metode untuk membuat pasangan terlibat dalam tes, dan hasil lemparan mereka (jika ada).Metoderoll()
memanggil metode dengan nama yang sama dari setiap kubus, menyimpan hasil antara dan menandai fakta pelaksanaannya dengan sebuah bendera isRolled
. Harap dicatat bahwa hasil akhir lemparan tidak dihitung dengan segera - ada metode khusus untuk ini calculateResult()
, yang hasilnya adalah menuliskan nilai akhir ke properti result
. Mengapa ini dibutuhkan? Untuk efek dramatis. Metode roll()
ini akan dijalankan beberapa kali, setiap kali pada wajah kubus nilai yang berbeda akan ditampilkan (seperti dalam kehidupan nyata). Dan hanya ketika kubus tenang di atas meja, kita belajar nasib kita hasil akhir (perbedaan antara nilai-nilai kubus pahlawan dan kubus lawan). Untuk menghilangkan stres, saya akan mengatakan bahwa hasil 0 akan dianggap lulus tes.Keadaan mesin game.Objek canggih diselesaikan, sekarang segalanya lebih sederhana. Tidak akan menjadi penemuan yang hebat untuk mengatakan bahwa kita perlu mengendalikan "kemajuan" mesin game saat ini, tahap atau fase di mana ia berada. Penghitungan khusus berguna untuk ini. enum class GamePhase { SCENARIO_START, HERO_TURN_START, HERO_TURN_END, LOCATION_BEFORE_EXPLORATION, LOCATION_ENCOUNTER_STAT, LOCATION_ENCOUNTER_DIVINE, LOCATION_AFTER_EXPLORATION, GAME_LOSS }
Sebenarnya, ada lebih banyak fase, tetapi kami memilih hanya yang digunakan dalam contoh kami. Untuk mengubah fase mesin gim, kami akan menggunakan metode changePhaseX()
, di mana X
nilainya dari daftar di atas. Dalam metode ini, semua variabel internal mesin akan dikurangi menjadi nilai yang memadai untuk awal fase yang sesuai, tetapi lebih pada nanti.PesanMenjaga keadaan mesin game tidak cukup. Penting juga bagi pengguna untuk memberi tahu tentangnya - jika tidak, bagaimana cara yang terakhir mengetahui apa yang terjadi di layarnya? Itu sebabnya kami membutuhkan daftar lain. enum class StatusMessage { EMPTY, CHOOSE_DICE_PERFORM_CHECK, END_OF_TURN_DISCARD_EXTRA, END_OF_TURN_DISCARD_OPTIONAL, CHOOSE_ACTION_BEFORE_EXPLORATION, CHOOSE_ACTION_AFTER_EXPLORATION, ENCOUNTER_PHYSICAL, ENCOUNTER_SOMATIC, ENCOUNTER_MENTAL, ENCOUNTER_VERBAL, ENCOUNTER_DIVINE, DIE_ACQUIRE_SUCCESS, DIE_ACQUIRE_FAILURE, GAME_LOSS_OUT_OF_TIME }
Seperti yang Anda lihat, semua status yang mungkin dari contoh kami dijelaskan oleh nilai-nilai enumerasi ini. Untuk masing-masing dari mereka, baris teks disediakan, yang akan ditampilkan di layar (kecuali EMPTY
- ini adalah makna khusus), tetapi kita akan belajar tentang ini nanti.TindakanUntuk komunikasi antara pengguna dan mesin game, pesan sederhana tidak cukup. Penting juga untuk memberi tahu tindakan pertama yang dapat dia lakukan saat ini (untuk meneliti, melewati penghambat, menyelesaikan langkah - itu semua baik). Untuk melakukan ini, kami akan mengembangkan kelas khusus. class Action( val type: Type, var isEnabled: Boolean = true, val data: Int = 0 ) { enum class Type { NONE,
Enumerasi internal Type
menjelaskan jenis tindakan yang dilakukan. Bidang ini isEnabled
diperlukan untuk menampilkan tindakan dalam keadaan tidak aktif. Yaitu, untuk melaporkan bahwa tindakan ini biasanya tersedia, tetapi saat ini karena alasan tertentu tidak dapat dilakukan (tampilan seperti itu jauh lebih informatif daripada ketika tindakan tidak ditampilkan sama sekali). Properti data
(diperlukan untuk beberapa jenis tindakan) menyimpan nilai khusus yang mengkomunikasikan beberapa detail tambahan (misalnya, indeks posisi yang dipilih oleh pengguna atau jumlah item yang dipilih dari daftar).KlasAction
adalah "antarmuka" utama antara mesin game dan sistem input-output (sekitar yang di bawah). Karena sering ada beberapa tindakan (jika tidak, mengapa kemudian memilih?), Mereka akan digabungkan menjadi kelompok (daftar). Alih-alih menggunakan koleksi standar, kami akan menulis koleksi kami sendiri yang diperluas. class ActionList : Iterable<Action> { private val actions = mutableListOf<Action>() val size get() = actions.size fun add(action: Action): ActionList { actions.add(action) return this } fun add(type: Action.Type, enabled: Boolean = true): ActionList { add(Action(type, enabled)) return this } fun addAll(actions: ActionList): ActionList { actions.forEach { add(it) } return this } fun remove(type: Action.Type): ActionList { actions.removeIf { it.type == type } return this } operator fun get(index: Int) = actions[index] operator fun get(type: Action.Type) = actions.find { it.type == type } override fun iterator(): Iterator<Action> = ActionListIterator() private inner class ActionListIterator : Iterator<Action> { private var position = -1 override fun hasNext() = (actions.size > position + 1) override fun next() = actions[++position] } companion object { val EMPTY get() = ActionList() } }
Kelas ini berisi banyak metode berbeda untuk menambah dan menghapus tindakan dari daftar (yang dapat dirantai bersama), serta mendapatkan keduanya berdasarkan indeks dan jenis (catat "overload" get()
- operator braket persegi berlaku untuk daftar kami). Implementasi antarmuka Iterator
memungkinkan kita untuk melakukan berbagai manipulasi aliran (fungsionalitas, aha) dengan segala macam kelas omong kosong gila kita . Nilai KOSONG juga disediakan untuk membuat daftar kosong dengan cepat.Layar.Akhirnya, daftar lain yang menggambarkan berbagai jenis konten yang sedang ditampilkan ... Anda melihat saya dan mengedipkan mata, saya tahu. Ketika saya mulai memikirkan cara untuk menggambarkan kelas ini dengan lebih jelas, saya memukul kepala saya di atas meja, karena saya tidak dapat benar-benar memahami apa pun. Pahami diri Anda, saya harap. enum class GameScreen { HERO_TURN_START, LOCATION_INTERIOR, GAME_LOSS }
Hanya yang dipilih yang digunakan dalam contoh. Metode rendering terpisah akan disediakan untuk masing-masing ... Saya sekali lagi menjelaskan dengan tidak jelas."Tampilan" dan "input".Dan sekarang kita akhirnya sampai pada poin paling penting - interaksi mesin game dengan pengguna (pemain). Jika pengantar yang begitu panjang belum membuat Anda bosan, maka Anda mungkin ingat bahwa kami sepakat untuk secara fungsional memisahkan kedua bagian ini dari yang lain. Oleh karena itu, alih-alih implementasi spesifik dari sistem I / O, kami hanya akan menyediakan antarmuka. Lebih tepatnya, dua.Antarmuka pertamaGameRenderer
, dirancang untuk menampilkan gambar di layar. Saya mengingatkan Anda bahwa kami abstrak dari ukuran layar, dari perpustakaan grafik tertentu, dll. Kami hanya mengirim perintah: "gambar saya ini" - dan Anda yang mengerti percakapan kami yang cadel tentang layar sudah menduga bahwa masing-masing layar ini memiliki metode sendiri di dalam antarmuka. interface GameRenderer { fun drawHeroTurnStart(hero: Hero) fun drawLocationInteriorScreen( location: Location, heroesAtLocation: List<Hero>, timer: Int, currentHero: Hero, battleCheck: DieBattleCheck?, encounteredDie: DiePair?, pickedDice: HandMask, activePositions: HandMask, statusMessage: StatusMessage, actions: ActionList ) fun drawGameLoss(message: StatusMessage) }
Saya pikir tidak perlu untuk penjelasan tambahan di sini - tujuan dari semua objek yang ditransfer dibahas secara rinci di atas.Untuk input pengguna, kami menerapkan antarmuka yang berbeda - GameInteractor
(ya, skrip periksa ejaan akan selalu selalu menekankan kata ini, meskipun sepertinya ...). Metodenya akan meminta pemain untuk perintah yang diperlukan untuk berbagai situasi: pilih tindakan dari daftar yang diusulkan, pilih elemen dari daftar, pilih kubus dari tangan, setidaknya tekan sesuatu, dll. Harus segera dicatat bahwa input terjadi secara serempak (game adalah langkah-demi-langkah), yaitu, eksekusi dari loop game ditunda hingga pengguna merespons permintaan. interface GameInteractor{ fun anyInput() fun pickAction(list: ActionList): Action fun pickDiceFromHand(activePositions: HandMask, actions: ActionList): Action }
Tentang metode terakhir sedikit lagi. Seperti namanya, dari mengundang pengguna untuk memilih kubus dari tangan, menyediakan objek HandMask
- jumlah posisi aktif. Eksekusi metode akan berlanjut sampai beberapa dari mereka dipilih - dalam kasus ini, metode akan mengembalikan aksi ketik HAND_POSITION
(atau HAND_ALLY_POSITION
, mda) dengan jumlah posisi yang dipilih di lapangan data
. Selain itu, dimungkinkan untuk memilih tindakan lain (misalnya, CONFIRM
atau CANCEL
) dari objek ActionList
. Implementasi metode input harus membedakan antara situasi ketika bidang isEnabled
diatur ke false
dan mengabaikan input pengguna dari tindakan tersebut.Kelas mesin game.Kami memeriksa semua yang diperlukan untuk bekerja, saatnya telah tiba dan mesin untuk diimplementasikan. Buat kelasGame
dengan konten berikut:Maaf, ini tidak diperlihatkan kepada orang yang mudah dipengaruhi. class Game( private val renderer: GameRenderer, private val interactor: GameInteractor, private val scenario: Scenario, private val locations: List<Location>, private val heroes: List<Hero>) { private var timer = 0 private var currentHeroIndex = -1 private lateinit var currentHero: Hero private lateinit var currentLocation: Location private val deterrentPile = Pile() private var encounteredDie: DiePair? = null private var battleCheck: DieBattleCheck? = null private val activeHandPositions = HandMask() private val pickedHandPositions = HandMask() private var phase: GamePhase = GamePhase.SCENARIO_START private var screen = GameScreen.SCENARIO_INTRO private var statusMessage = StatusMessage.EMPTY private var actions: ActionList = ActionList.EMPTY fun start() { if (heroes.isEmpty()) throw IllegalStateException("Heroes list is empty!") if (locations.isEmpty()) throw IllegalStateException("Location list is empty!") heroes.forEach { it.isAlive = true } timer = scenario.initialTimer
Metode start()
- titik masuk ke permainan. Di sini variabel diinisialisasi, pahlawan ditimbang, tangan diisi dengan kubus, dan wartawan bersinar dengan kamera dari semua sisi. Siklus utama akan diluncurkan setiap saat, setelah itu tidak dapat dihentikan lagi. Metode ini drawInitialHand()
berbicara sendiri (sepertinya kami tidak mempertimbangkan kode metode drawOfType()
kelas Bag
, tetapi setelah berjalan jauh bersama-sama, Anda dapat menulis kode ini sendiri). Metode ini refillHeroHand()
memiliki dua opsi (tergantung pada nilai argumen redrawScreen
): cepat dan sunyi (ketika Anda harus mengisi tangan semua pahlawan di awal permainan), dan bersuara keras dengan sekelompok patho, ketika pada akhir gerakan Anda harus dengan hati-hati mengeluarkan kubus dari tas, membawa tangan ke ukuran yang tepat.Sekelompok metode dengan nama dimulai denganchangePhase
, - seperti yang telah kami katakan, mereka berfungsi untuk mengubah fase game saat ini dan terlibat dalam penugasan nilai-nilai yang sesuai dari variabel game. Di sini, daftar dibentuk di actions
mana karakteristik aksi dari fase ini ditambahkan.Metode utilitas pickDiceFromHand()
dalam bentuk penawaran umum dengan pemilihan kubus dari tangan. Objek dari kelas HandMaskRule
yang dikenal yang mendefinisikan aturan seleksi dilewatkan di sini . Ini juga menunjukkan kemampuan untuk menolak pemilihan ( allowCancel
), serta fungsi onEachLoop
yang kodenya harus dipanggil setiap kali daftar kubus yang dipilih diubah (biasanya layar redraw). Kubus yang dipilih dengan metode ini dapat dirakit dari tangan menggunakan collectPickedDice()
dan metode collectPickedAllyDice()
.Metode utilitas lainperformStatDieAcquireCheck()
sepenuhnya mengimplementasikan pahlawan yang lulus tes untuk akuisisi kubus baru. Peran sentral dalam metode ini dimainkan oleh objek DieBattleCheck
. Proses dimulai dengan pemilihan kubus dengan metode pickDiceFromHand()
(pada setiap langkah daftar "peserta" diperbarui DieBattleCheck
). Kubus yang dipilih dikeluarkan dari tangan, setelah itu "roll" terjadi - setiap die memperbarui nilainya (delapan kali berturut-turut), setelah itu hasilnya dihitung dan ditampilkan. Pada roll yang sukses, mati baru jatuh ke tangan pahlawan. Kubus yang berpartisipasi dalam tes dapat ditahan (jika berwarna biru), atau dibuang (jika shouldDiscard = true
), atau disembunyikan di dalam tas (jika shouldDiscard = false
).Metode utamaprocessCycle()
berisi loop tak terbatas (saya minta tanpa pingsan) di mana layar digambar terlebih dahulu, lalu pengguna diminta untuk input, maka input ini diproses - dengan semua konsekuensi berikutnya. Metode drawScreen()
memanggil metode antarmuka yang diinginkan GameRenderer
(tergantung pada nilai saat ini screen
), meneruskannya objek yang diperlukan untuk input.Juga, kelas berisi beberapa metode pembantu: checkLocationCanBeExplored()
, checkHeroCanAttemptStatCheck()
dan checkHeroCanAcquireDie()
. Nama mereka berbicara sendiri, oleh karena itu kami tidak akan membahasnya secara rinci. Dan ada juga pemanggilan metode kelas Audio
, yang digarisbawahi oleh garis bergelombang merah. Komentari mereka untuk saat ini - kami akan mempertimbangkan tujuan mereka nanti.Siapa yang tidak mengerti apa-apa, di sini adalah diagram (untuk kejelasan, untuk berbicara): Itu saja, game sudah siap (hehe). Ada hal-hal kecil yang nyata, tentang mereka di bawah.Langkah Sembilan. Tampilkan Gambar
Jadi kita sampai pada topik utama percakapan hari ini - komponen grafis dari aplikasi. Seperti yang Anda ingat, tugas kami adalah mengimplementasikan antarmuka GameRenderer
dan tiga metode, dan karena masih belum ada artis berbakat di tim kami, kami akan melakukannya sendiri menggunakan pseudografi. Tetapi untuk memulainya, alangkah baiknya untuk memahami apa yang umumnya kita harapkan di pintu keluar. Dan kami ingin melihat tiga layar dari sekitar konten berikut:Layar 2. Informasi tentang area dan pahlawan saat ini Layar 3. Pesan Skrip Rugi Saya pikir mayoritas sudah menyadari bahwa gambar yang disajikan berbeda dari semua yang biasa kita lihat di konsol aplikasi Java, dan bahwa fitur yang biasa prinltn()
jelas tidak akan cukup bagi kita. Saya juga ingin bisa melompat ke tempat sewenang-wenang di layar dan menggambar simbol dalam berbagai warna. Kode ANSI Chip dan Dalebergegas membantu kami . Dengan mengirimkan urutan karakter yang aneh ke output, Anda dapat mencapai efek yang tidak kalah aneh: mengubah warna teks / latar belakang, cara karakter digambar, posisi kursor di layar, dan banyak lagi. Tentu saja, kami tidak akan memperkenalkan mereka dalam bentuk murni - kami akan menyembunyikan implementasi di balik metode kelas. Dan kita tidak akan menulis kelas itu sendiri dari awal - untungnya, orang pintar melakukannya untuk kita. Kami hanya perlu mengunduh dan menghubungkan beberapa perpustakaan ringan ke proyek, misalnya, Jansi : <dependency> <groupId>org.fusesource.jansi</groupId> <artifactId>jansi</artifactId> <version>1.17.1</version> <scope>compile</scope> </dependency>
Dan Anda dapat mulai membuat. Perpustakaan ini memberi kita objek kelas Ansi
(diperoleh sebagai hasil dari panggilan statis Ansi.ansi()
) dengan banyak metode mudah yang bisa dirantai. Ia bekerja berdasarkan prinsip StringBuilder
'a - pertama kita membentuk objek, lalu mengirimkannya untuk dicetak. Dari metode yang bermanfaat kita akan menemukan berguna:a()
- untuk menampilkan karakter;cursor()
- untuk memindahkan kursor di layar;eraseLine()
- seakan berbicara sendiri;eraseScreen()
- sama halnya;fg(), bg(), fgBright(), bgBright()
- metode yang sangat merepotkan untuk bekerja dengan teks dan warna latar belakang - kita akan membuatnya sendiri, lebih menyenangkan;reset()
- untuk mengatur ulang pengaturan warna yang diatur, layar berkedip, dll.
Mari kita buat kelas ConsoleRenderer
dengan metode utilitas yang mungkin berguna bagi kita dalam pekerjaan kita. Versi pertama akan terlihat seperti ini: abstract class ConsoleRenderer() { protected lateinit var ansi: Ansi init { AnsiConsole.systemInstall() clearScreen() resetAnsi() } private fun resetAnsi() { ansi = Ansi.ansi() } fun clearScreen() { print(Ansi.ansi().eraseScreen(Ansi.Erase.ALL).cursor(1, 1)) } protected fun render() { print(ansi.toString()) resetAnsi() } }
Metode resetAnsi()
menciptakan objek (kosong) baru Ansi
, yang akan diisi dengan perintah yang diperlukan (bergerak, output, dll.). Setelah selesai mengisi, objek yang dihasilkan dikirim untuk dicetak dengan metode ini render()
, dan variabel diinisialisasi dengan objek baru. Belum ada yang rumit, kan? Dan jika demikian, maka kita akan mulai mengisi kelas ini dengan metode lain yang bermanfaat.Mari kita mulai dengan ukurannya. Konsol standar sebagian besar terminal berukuran 80x24. Kami mencatat fakta ini dengan dua konstanta CONSOLE_WIDTH
dan CONSOLE_HEIGHT
. Kami tidak akan terikat pada nilai-nilai spesifik dan akan mencoba membuat desain sekokoh mungkin (seperti di web). Penomoran koordinat dimulai dengan satu, koordinat pertama adalah satu baris, yang kedua adalah kolom. Mengetahui semua ini, kami menulis metode utilitasdrawHorizontalLine()
untuk mengisi string yang ditentukan dengan karakter yang ditentukan. protected fun drawHorizontalLine(offsetY: Int, filler: Char) { ansi.cursor(offsetY, 1) (1..CONSOLE_WIDTH).forEach { ansi.a(filler) }
Sekali lagi, saya mengingatkan Anda bahwa menjalankan perintah a()
atau cursor()
tidak menyebabkan efek instan, tetapi hanya menambahkan Ansi
urutan perintah yang sesuai ke objek . Hanya ketika urutan ini dikirim untuk dicetak yang akan kita lihat di layar.Tidak ada perbedaan mendasar antara menggunakan siklus klasik for
dan pendekatan fungsional dengan ClosedRange
dan forEach{}
- masing-masing pengembang memutuskan sendiri apa yang lebih nyaman baginya. Namun, saya akan terus membodohi kepala Anda dengan fungsionalisme, hanya karena saya seorang monyet yang mencintai segala sesuatu yang baru dan tanda kurung mengkilap tidak terbungkus ke baris baru dan kode terlihat lebih kompak.Kami menerapkan metode utilitas lain drawBlankLine()
yang melakukan hal yang sama sepertidrawHorizontalLine(offsetY, ' ')
, hanya dengan ekstensi. Terkadang kita perlu membuat garis kosong tidak sepenuhnya, tetapi meninggalkan garis vertikal di awal dan akhir (frame, yeah). Kode akan terlihat seperti ini: protected fun drawBlankLine(offsetY: Int, drawBorders: Boolean = true) { ansi.cursor(offsetY, 1) if (drawBorders) { ansi.a('│') (2 until CONSOLE_WIDTH).forEach { ansi.a(' ') } ansi.a('│') } else { ansi.eraseLine(Ansi.Erase.ALL) } }
Bagaimana, Anda tidak pernah menggambar bingkai dari pseudografi? Simbol dapat dimasukkan langsung ke kode sumber. Tahan tombol Alt dan ketik kode karakter pada keypad numerik. Lalu lepaskan. Kode ASCII yang kita butuhkan dalam pengkodean apa pun adalah sama, ini adalah paket minimum gentleman:Dan kemudian, seperti di minecraft, kemungkinan hanya dibatasi oleh batas imajinasi Anda. Dan ukuran layarnya. protected fun drawCenteredCaption(offsetY: Int, text: String, color: Color, drawBorders: Boolean = true) { val center = (CONSOLE_WIDTH - text.length) / 2 ansi.cursor(offsetY, 1) ansi.a(if (drawBorders) '│' else ' ') (2 until center).forEach { ansi.a(' ') } ansi.color(color).a(text).reset() (text.length + center until CONSOLE_WIDTH).forEach { ansi.a(' ') } ansi.a(if (drawBorders) '│' else ' ') }
Mari kita bicara sedikit tentang bunga. Kelas Ansi
berisi konstanta Color
untuk delapan warna primer (hitam, biru, hijau, cyan, merah, ungu, kuning, abu-abu), yang harus Anda lewati ke input metode fg()/bg()
untuk versi gelap atau fgBright()/bgBright()
untuk yang terang, yang sangat merepotkan untuk dilakukan, karena mengidentifikasi warna dengan cara, satu nilai tidak cukup bagi kami - kami membutuhkan setidaknya dua (warna dan kecerahan). Oleh karena itu, kami akan membuat daftar konstanta dan metode ekstensi kami (serta warna yang mengikat peta untuk jenis kubus dan kelas pahlawan): protected enum class Color { BLACK, DARK_BLUE, DARK_GREEN, DARK_CYAN, DARK_RED, DARK_MAGENTA, DARK_YELLOW, LIGHT_GRAY, DARK_GRAY, LIGHT_BLUE, LIGHT_GREEN, LIGHT_CYAN, LIGHT_RED, LIGHT_MAGENTA, LIGHT_YELLOW, WHITE } protected fun Ansi.color(color: Color?): Ansi = when (color) { Color.BLACK -> fgBlack() Color.DARK_BLUE -> fgBlue() Color.DARK_GREEN -> fgGreen() Color.DARK_CYAN -> fgCyan() Color.DARK_RED -> fgRed() Color.DARK_MAGENTA -> fgMagenta() Color.DARK_YELLOW -> fgYellow() Color.LIGHT_GRAY -> fg(Ansi.Color.WHITE) Color.DARK_GRAY -> fgBrightBlack() Color.LIGHT_BLUE -> fgBrightBlue() Color.LIGHT_GREEN -> fgBrightGreen() Color.LIGHT_CYAN -> fgBrightCyan() Color.LIGHT_RED -> fgBrightRed() Color.LIGHT_MAGENTA -> fgBrightMagenta() Color.LIGHT_YELLOW -> fgBrightYellow() Color.WHITE -> fgBright(Ansi.Color.WHITE) else -> this } protected fun Ansi.background(color: Color?): Ansi = when (color) { Color.BLACK -> ansi.bg(Ansi.Color.BLACK) Color.DARK_BLUE -> ansi.bg(Ansi.Color.BLUE) Color.DARK_GREEN -> ansi.bgGreen() Color.DARK_CYAN -> ansi.bg(Ansi.Color.CYAN) Color.DARK_RED -> ansi.bgRed() Color.DARK_MAGENTA -> ansi.bgMagenta() Color.DARK_YELLOW -> ansi.bgYellow() Color.LIGHT_GRAY -> ansi.bg(Ansi.Color.WHITE) Color.DARK_GRAY -> ansi.bgBright(Ansi.Color.BLACK) Color.LIGHT_BLUE -> ansi.bgBright(Ansi.Color.BLUE) Color.LIGHT_GREEN -> ansi.bgBrightGreen() Color.LIGHT_CYAN -> ansi.bgBright(Ansi.Color.CYAN) Color.LIGHT_RED -> ansi.bgBrightRed() Color.LIGHT_MAGENTA -> ansi.bgBright(Ansi.Color.MAGENTA) Color.LIGHT_YELLOW -> ansi.bgBrightYellow() Color.WHITE -> ansi.bgBright(Ansi.Color.WHITE) else -> this } protected val dieColors = mapOf( Die.Type.PHYSICAL to Color.LIGHT_BLUE, Die.Type.SOMATIC to Color.LIGHT_GREEN, Die.Type.MENTAL to Color.LIGHT_MAGENTA, Die.Type.VERBAL to Color.LIGHT_YELLOW, Die.Type.DIVINE to Color.LIGHT_CYAN, Die.Type.WOUND to Color.DARK_GRAY, Die.Type.ENEMY to Color.DARK_RED, Die.Type.VILLAIN to Color.LIGHT_RED, Die.Type.OBSTACLE to Color.DARK_YELLOW, Die.Type.ALLY to Color.WHITE ) protected val heroColors = mapOf( Hero.Type.BRAWLER to Color.LIGHT_BLUE, Hero.Type.HUNTER to Color.LIGHT_GREEN )
Sekarang, masing-masing dari 16 warna yang tersedia diidentifikasi secara unik oleh satu konstanta. Kami akan menulis beberapa metode utilitas lagi, tetapi sebelum itu kami akan menemukan satu hal lagi:Di mana menyimpan konstanta untuk string teks?“Konstanta string harus dikeluarkan dalam file terpisah sehingga mereka disimpan semua di satu tempat - ini membuatnya lebih mudah untuk dipelihara. Dan itu juga penting untuk pelokalan ... "Konstanta string perlu dipindahkan ke file yang terpisah ... yah, ya. Kami akan bertahan. Mekanisme Java standar untuk bekerja dengan sumber daya semacam ini adalah objek java.util.ResourceBundle
yang bekerja dengan file .properties
. Di sini kita mulai dari file seperti itu: # Game status messages choose_dice_perform_check=Choose dice to perform check: end_of_turn_discard_extra=END OF TURN: Discard extra dice: end_of_turn_discard_optional=END OF TURN: Discard any dice, if needed: choose_action_before_exploration=Choose your action: choose_action_after_exploration=Already explored this turn. Choose what to do now: encounter_physical=Encountered PHYSICAL die. Need to pass respective check or lose this die. encounter_somatic=Encountered SOMATIC die. Need to pass respective check or lose this die. encounter_mental=Encountered MENTAL die. Need to pass respective check or lose this die. encounter_verbal=Encountered VERBAL die. Need to pass respective check or lose this die. encounter_divine=Encountered DIVINE die. Can be acquired automatically (no checks needed): die_acquire_success=You have acquired the die! die_acquire_failure=You have failed to acquire the die. game_loss_out_of_time=You ran out of time # Die types physical=PHYSICAL somatic=SOMATIC mental=MENTAL verbal=VERBAL divine=DIVINE ally=ALLY wound=WOUND enemy=ENEMY villain=VILLAIN obstacle=OBSTACLE # Hero types and descriptions brawler=Brawler hunter=Hunter # Various labels avg=avg bag=Bag bag_size=Bag size class=Class closed=Closed discard=Discard empty=Empty encountered=Encountered fail=Fail hand=Hand heros_turn=%s's turn max=max min=min perform_check=Perform check: pile=Pile received_new_die=Received new die result=Result success=Success sum=sum time=Time total=Total # Action names and descriptions action_confirm_key=ENTER action_confirm_name=Confirm action_cancel_key=ESC action_cancel_name=Cancel action_explore_location_key=E action_explore_location_name=xplore action_finish_turn_key=F action_finish_turn_name=inish action_hide_key=H action_hide_name=ide action_discard_key=D action_discard_name=iscard action_acquire_key=A action_acquire_name=cquire action_leave_key=L action_leave_name=eave action_forfeit_key=F action_forfeit_name=orfeit
Setiap baris berisi pasangan nilai kunci, dipisahkan oleh karakter =
. Anda dapat meletakkan file di mana saja - yang utama adalah path ke file tersebut menjadi bagian dari classpath. Harap dicatat bahwa teks untuk tindakan terdiri dari dua bagian: huruf pertama tidak hanya disorot dengan warna kuning ketika ditampilkan di layar, tetapi juga menentukan tombol yang harus ditekan untuk melakukan tindakan ini. Karena itu, nyaman untuk menyimpannya secara terpisah.Kami abstrak, bagaimanapun, dari format tertentu (di Android, misalnya, string disimpan secara berbeda) dan menggambarkan antarmuka untuk memuat konstanta string. interface StringLoader { fun loadString(key: String): String }
Kuncinya ditransmisikan ke input, outputnya adalah garis tertentu. Implementasinya semudah antarmuka itu sendiri (misalkan file terletak di sepanjang jalur src/main/resources/text/strings.properties
). class PropertiesStringLoader() : StringLoader { private val properties = ResourceBundle.getBundle("text.strings") override fun loadString(key: String) = properties.getString(key) ?: "" }
Sekarang tidak akan sulit untuk menerapkan metode drawStatusMessage()
untuk menampilkan kondisi mesin game saat ini ( StatusMessage
) di layar dan metode drawActionList()
untuk menampilkan daftar tindakan yang tersedia ( ActionList
). Serta metode resmi lainnya yang hanya diinginkan oleh jiwa.Ada banyak kode, sebagian sudah kita lihat ... jadi di sini ada spoiler untuk Anda abstract class ConsoleRenderer(private val strings: StringLoader) { protected lateinit var ansi: Ansi init { AnsiConsole.systemInstall() clearScreen() resetAnsi() } protected fun loadString(key: String) = strings.loadString(key) private fun resetAnsi() { ansi = Ansi.ansi() } fun clearScreen() { print(Ansi.ansi().eraseScreen(Ansi.Erase.ALL).cursor(1, 1)) } protected fun render() { ansi.cursor(CONSOLE_HEIGHT, CONSOLE_WIDTH) System.out.print(ansi.toString()) resetAnsi() } protected fun drawBigNumber(offsetX: Int, offsetY: Int, number: Int): Unit = with(ansi) { var currentX = offsetX cursor(offsetY, currentX) val text = number.toString() text.forEach { when (it) { '0' -> { cursor(offsetY, currentX) a(" ███ ") cursor(offsetY + 1, currentX) a("█ █ ") cursor(offsetY + 2, currentX) a("█ █ ") cursor(offsetY + 3, currentX) a("█ █ ") cursor(offsetY + 4, currentX) a(" ███ ") } '1' -> { cursor(offsetY, currentX) a(" █ ") cursor(offsetY + 1, currentX) a(" ██ ") cursor(offsetY + 2, currentX) a("█ █ ") cursor(offsetY + 3, currentX) a(" █ ") cursor(offsetY + 4, currentX) a("█████ ") } '2' -> { cursor(offsetY, currentX) a(" ███ ") cursor(offsetY + 1, currentX) a("█ █ ") cursor(offsetY + 2, currentX) a(" █ ") cursor(offsetY + 3, currentX) a(" █ ") cursor(offsetY + 4, currentX) a("█████ ") } '3' -> { cursor(offsetY, currentX) a("████ ") cursor(offsetY + 1, currentX) a(" █ ") cursor(offsetY + 2, currentX) a(" ██ ") cursor(offsetY + 3, currentX) a(" █ ") cursor(offsetY + 4, currentX) a("████ ") } '4' -> { cursor(offsetY, currentX) a(" █ ") cursor(offsetY + 1, currentX) a(" ██ ") cursor(offsetY + 2, currentX) a(" █ █ ") cursor(offsetY + 3, currentX) a("█████ ") cursor(offsetY + 4, currentX) a(" █ ") } '5' -> { cursor(offsetY, currentX) a("█████ ") cursor(offsetY + 1, currentX) a("█ ") cursor(offsetY + 2, currentX) a("████ ") cursor(offsetY + 3, currentX) a(" █ ") cursor(offsetY + 4, currentX) a("████ ") } '6' -> { cursor(offsetY, currentX) a(" ███ ") cursor(offsetY + 1, currentX) a("█ ") cursor(offsetY + 2, currentX) a("████ ") cursor(offsetY + 3, currentX) a("█ █ ") cursor(offsetY + 4, currentX) a(" ███ ") } '7' -> { cursor(offsetY, currentX) a("█████ ") cursor(offsetY + 1, currentX) a(" █ ") cursor(offsetY + 2, currentX) a(" █ ") cursor(offsetY + 3, currentX) a(" █ ") cursor(offsetY + 4, currentX) a(" █ ") } '8' -> { cursor(offsetY, currentX) a(" ███ ") cursor(offsetY + 1, currentX) a("█ █ ") cursor(offsetY + 2, currentX) a(" ███ ") cursor(offsetY + 3, currentX) a("█ █ ") cursor(offsetY + 4, currentX) a(" ███ ") } '9' -> { cursor(offsetY, currentX) a(" ███ ") cursor(offsetY + 1, currentX) a("█ █ ") cursor(offsetY + 2, currentX) a(" ████ ") cursor(offsetY + 3, currentX) a(" █ ") cursor(offsetY + 4, currentX) a(" ███ ") } } currentX += 6 } } protected fun drawHorizontalLine(offsetY: Int, filler: Char) { ansi.cursor(offsetY, 1) (1..CONSOLE_WIDTH).forEach { ansi.a(filler) } } protected fun drawBlankLine(offsetY: Int, drawBorders: Boolean = true) { ansi.cursor(offsetY, 1) if (drawBorders) { ansi.a('│') (2 until CONSOLE_WIDTH).forEach { ansi.a(' ') } ansi.a('│') } else { ansi.eraseLine(Ansi.Erase.ALL) } } protected fun drawCenteredCaption(offsetY: Int, text: String, color: Color, drawBorders: Boolean = true) { val center = (CONSOLE_WIDTH - text.length) / 2 ansi.cursor(offsetY, 1) ansi.a(if (drawBorders) '│' else ' ') (2 until center).forEach { ansi.a(' ') } ansi.color(color).a(text).reset() (text.length + center until CONSOLE_WIDTH).forEach { ansi.a(' ') } ansi.a(if (drawBorders) '│' else ' ') } protected fun drawStatusMessage(offsetY: Int, message: StatusMessage, drawBorders: Boolean = true) {
Mengapa kita semua melakukan ini, Anda bertanya? Ya, untuk mewarisi implementasi antarmuka kami dari kelas yang luar biasa ini GameRenderer
.Ini adalah bagaimana implementasi metode pertama yang paling sederhana akan terlihat seperti: override fun drawGameLoss(message: StatusMessage) { val centerY = CONSOLE_HEIGHT / 2 (1 until centerY).forEach { drawBlankLine(it, false) } val data = loadString(message.toString().toLowerCase()).toUpperCase() drawCenteredCaption(centerY, data, LIGHT_RED, false) (centerY + 1..CONSOLE_HEIGHT).forEach { drawBlankLine(it, false) } render() }
Tidak ada yang supernatural, hanya satu baris teks ( data
) yang digambar merah di tengah layar ( drawCenteredCaption()
). Sisa kode mengisi sisa layar dengan garis kosong. Mungkin seseorang akan bertanya mengapa ini perlu - lagipula clearScreen()
, ada metode , cukup menyebutnya di awal metode, hapus layar, dan kemudian gambar teks yang diinginkan. Sayangnya, ini adalah pendekatan malas yang tidak akan kita gunakan. Alasannya sangat sederhana: dengan pendekatan ini, beberapa posisi pada layar digambar dua kali, yang mengarah pada kedipan yang terlihat, terutama ketika layar digambar secara berurutan beberapa kali berturut-turut (selama animasi). Karena itu, tugas kita bukan hanya menggambar karakter yang tepat di tempat yang tepat, tetapi untuk mengisi keseluruhansisa layar dengan karakter kosong (sehingga artefak dari render lainnya tidak tetap di sana). Dan tugas ini tidak sesederhana itu.Metode berikut mengikuti prinsip ini: override fun drawHeroTurnStart(hero: Hero) { val centerY = (CONSOLE_HEIGHT - 5) / 2 (1 until centerY).forEach { drawBlankLine(it, false) } ansi.color(heroColors[hero.type]) drawHorizontalLine(centerY, '─') drawHorizontalLine(centerY + 4, '─') ansi.reset() ansi.cursor(centerY + 1, 1).eraseLine() ansi.cursor(centerY + 3, 1).eraseLine() ansi.cursor(centerY + 2, 1) val text = String.format(loadString("heros_turn"), hero.name.toUpperCase()) val index = text.indexOf(hero.name.toUpperCase()) val center = (CONSOLE_WIDTH - text.length) / 2 ansi.cursor(centerY + 2, center) ansi.eraseLine(Ansi.Erase.BACKWARD) ansi.a(text.substring(0, index)) ansi.color(heroColors[hero.type]).a(hero.name.toUpperCase()).reset() ansi.a(text.substring(index + hero.name.length)) ansi.eraseLine(Ansi.Erase.FORWARD) (centerY + 5..CONSOLE_HEIGHT).forEach { drawBlankLine(it, false) } render() }
Di sini, selain teks tengah, ada juga dua garis horizontal (lihat tangkapan layar di atas). Harap dicatat bahwa huruf tengah ditampilkan dalam dua warna. Dan juga memastikan bahwa belajar matematika di sekolah masih bermanfaat.Kami melihat metode yang paling sederhana dan inilah saatnya untuk mengetahui implementasinya drawLocationInteriorScreen()
. Seperti yang Anda sendiri mengerti, akan ada urutan lebih banyak kode di sini. Selain itu, isi layar akan berubah secara dinamis sebagai respons terhadap tindakan pengguna dan harus selalu digambar ulang (kadang-kadang dengan animasi). Nah, untuk akhirnya menghabisi Anda: bayangkan bahwa selain tangkapan layar di atas, dalam kerangka metode ini, perlu untuk mengimplementasikan tampilan tiga lagi:1. Pertemuan dengan kubus dilepas dari tas 2. Memilih dadu untuk lulus tes Karena itu, inilah saran saya yang terbaik untuk Anda: jangan sorong semua kode menjadi satu metode. Bagi implementasi menjadi beberapa metode (bahkan jika masing-masing akan dipanggil hanya sekali). Nah, jangan lupa tentang "karet".Jika mulai beriak di mata Anda, berkediplah selama beberapa detik - ini akan membantu class ConsoleGameRenderer(loader: StringLoader) : ConsoleRenderer(loader), GameRenderer { private fun drawLocationTopPanel(location: Location, heroesAtLocation: List<Hero>, currentHero: Hero, timer: Int) { val closedString = loadString("closed").toLowerCase() val timeString = loadString("time") val locationName = location.name.toString().toUpperCase() val separatorX1 = locationName.length + if (location.isOpen) { 6 + if (location.bag.size >= 10) 2 else 1 } else { closedString.length + 7 } val separatorX2 = CONSOLE_WIDTH - timeString.length - 6 - if (timer >= 10) 1 else 0
Ada satu masalah kecil yang terkait dengan memeriksa operasi semua kode ini. Karena konsol IDE bawaan tidak mendukung urutan pelarian ANSI, Anda harus memulai aplikasi di terminal eksternal (kami sudah menulis skrip untuk meluncurkannya sebelumnya). Selain itu, dengan dukungan ANSI, tidak semuanya OK di Windows - sejauh yang saya tahu, hanya dengan versi 10 cmd.exe standar dapat menyenangkan kita dengan tampilan berkualitas tinggi (dan itu, dengan beberapa masalah yang tidak akan kita fokuskan). Dan PowerShell tidak segera belajar mengenali urutan (meskipun permintaan saat ini). Jika Anda kurang beruntung, jangan berkecil hati - selalu ada solusi alternatif ( ini, misalnya ). Dan kita melanjutkan.Langkah Sepuluh Input pengguna
Menampilkan gambar di layar masih setengah pertempuran. Sama pentingnya untuk menerima perintah kontrol dengan benar dari pengguna. Dan tugas ini, saya ingin memberi tahu Anda, dapat berubah menjadi jauh lebih sulit secara teknis untuk diimplementasikan daripada semua yang sebelumnya. Tetapi hal pertama yang pertama.Seingat Anda, kami dihadapkan pada kebutuhan untuk mengimplementasikan metode kelas GameInteractor
. Hanya ada tiga, tetapi mereka membutuhkan perhatian khusus. Pertama, sinkronisasi. Mesin permainan harus ditunda hingga pemain menekan tombol. Kedua, klik pemrosesan. Sayangnya, kapasitas kelas standar Reader
, Scanner
, Console
tidak cukup untuk mengenali ini yang paling mendesak: kita tidak memerlukan pengguna untuk tekan ENTER setelah setiap perintah. Kami membutuhkan sesuatu sepertiKeyListener
Tapi, tetapi terikat erat dengan kerangka Swing, dan aplikasi konsol kami tanpa semua perada grafis ini.Apa yang harus dilakukan
Mencari perpustakaan, tentu saja, dan kali ini pekerjaan mereka akan bergantung sepenuhnya pada kode asli. Apa artinya "selamat tinggal, lintas platform" ... Atau tidak? Sayangnya, saya belum menemukan perpustakaan yang mengimplementasikan fungsi sederhana dalam bentuk yang ringan, platform-independen. Sementara itu, mari kita perhatikan monster jLine , yang mengimplementasikan pemanen untuk membangun antarmuka pengguna tingkat lanjut (di konsol). Ya, ini memiliki implementasi asli, ya, mendukung Windows dan Linux / UNIX (dengan menyediakan perpustakaan yang sesuai). Dan ya, digunakan pada sebagian besar fungsinya, kita tidak perlu seratus tahun. Yang diperlukan hanyalah peluang kecil, yang tidak terdokumentasi dengan baik, pekerjaan yang sekarang akan kita analisis. <dependency> <groupId>jline</groupId> <artifactId>jline</artifactId> <version>2.14.6</version> <scope>compile</scope> </dependency>
Harap dicatat bahwa kita tidak perlu yang ketiga, versi terbaru, tetapi yang kedua, di mana ada kelas ConsoleReader
dengan metode readCharacter()
. Sesuai namanya, metode ini mengembalikan kode karakter yang ditekan pada keyboard (sambil bekerja secara sinkron, yang kami butuhkan). Sisanya adalah masalah teknis: kompilasi tabel korespondensi antara simbol dan jenis tindakan ( Action.Type
) dan, dengan mengklik satu, kembalikan yang lain.“Tahukah Anda bahwa tidak semua tombol pada keyboard dapat diwakili dengan satu karakter? Banyak tombol menggunakan urutan melarikan diri dari dua, tiga, empat karakter yang berbeda. Bagaimana dengan mereka? "Perlu dicatat bahwa tugas input rumit jika kita ingin mengenali "kunci non-karakter": panah, tombol-F, Beranda, Sisipkan, PgUp / Dn, Akhir, Hapus, num-pad, dan lainnya. Tetapi kami tidak mau, oleh karena itu kami akan melanjutkan. Mari kita buat kelas ConsoleInteractor
dengan metode layanan yang diperlukan. abstract class ConsoleInteractor { private val reader = ConsoleReader() private val mapper = mapOf( CONFIRM to 13.toChar(), CANCEL to 27.toChar(), EXPLORE_LOCATION to 'e', FINISH_TURN to 'f', ACQUIRE to 'a', LEAVE to 'l', FORFEIT to 'f', HIDE to 'h', DISCARD to 'd', ) protected fun read() = reader.readCharacter().toChar() protected open fun getIndexForKey(key: Char) = "1234567890abcdefghijklmnopqrstuvw".indexOf(key) }
Atur peta mapper
dan metode read()
. Selain itu, kami akan menyediakan metode yang getIndexForKey()
digunakan dalam situasi di mana kami perlu memilih item dari daftar atau kubus dari tangan. Masih mewarisi implementasi antarmuka kami dari kelas ini GameInteractor
.Dan, pada kenyataannya, kode: class ConsoleGameInteractor : ConsoleInteractor(), GameInteractor { override fun anyInput() { read() } override fun pickAction(list: ActionList): Action { while (true) { val key = read() list .filter(Action::isEnabled) .find { mapper[it.type] == key } ?.let { return it } } } override fun pickDiceFromHand(activePositions: HandMask, actions: ActionList) : Action { while (true) { val key = read() actions.forEach { if (mapper[it.type] == key && it.isEnabled) return it } when (key) { in '1'..'9' -> { val index = key - '1' if (activePositions.checkPosition(index)) { return Action(HAND_POSITION, data = index) } } '0' -> { if (activePositions.checkPosition(9)) { return Action(HAND_POSITION, data = 9) } } in 'a'..'f' -> { val allyIndex = key - 'a' if (activePositions.checkAllyPosition(allyIndex)) { return Action(HAND_ALLY_POSITION, data = allyIndex) } } } } } }
Penerapan metode kami cukup sopan dan santun agar tidak memunculkan berbagai omong kosong yang tidak memadai. Mereka sendiri memverifikasi bahwa tindakan yang dipilih aktif, dan posisi tangan yang dipilih termasuk dalam set valid. Dan saya berharap kita semua bersikap sopan kepada orang-orang di sekitar kita.Langkah sebelas. Suara dan musik
Tapi bagaimana mungkin tanpa mereka? Jika Anda pernah bermain game dengan suara dimatikan (misalnya, dengan tablet di bawah penutup sementara tidak ada orang di rumah melihat), Anda mungkin menyadari betapa Anda kehilangan banyak. Itu seperti bermain hanya setengah dari permainan. Banyak permainan tidak dapat dibayangkan tanpa iringan suara, karena banyak ini adalah persyaratan yang tidak dapat dicabut, meskipun ada situasi terbalik (misalnya, ketika tidak ada suara pada prinsipnya, atau mereka sangat menyedihkan sehingga akan lebih baik tanpa mereka). Untuk melakukan pekerjaan dengan baik sebenarnya tidak sesederhana seperti yang terlihat pada pandangan pertama (bukan tanpa alasan spesialis yang berkualifikasi melakukan ini di studio besar), tetapi karena itu, dalam banyak kasus, jauh lebih baik untuk memiliki komponen audio (setidaknya beberapa) dalam permainan Anda daripada tidak memilikinya sama sekali. Sebagai upaya terakhir, kualitas suara dapat ditingkatkan nanti,ketika waktu dan suasana hati memungkinkan.Karena kekhasan genre, game kami tidak akan dikarakterisasi oleh efek suara karya - jika Anda memainkan adaptasi digital dari game board, maka Anda mengerti apa yang saya maksud. Kedengarannya mengusir kebosanan mereka, segera menjadi membosankan dan setelah beberapa waktu bermain tanpa mereka tidak lagi terasa seperti kehilangan yang serius. Masalahnya diperparah oleh kenyataan bahwa tidak ada cara yang efektif untuk menghadapi fenomena ini. Ganti suara game dengan yang benar-benar berbeda, dan seiring waktu mereka akan menjadi jijik. Dalam permainan yang bagus, suara melengkapi permainan, mengungkap suasana aksi yang sedang berlangsung, membuatnya hidup - ini sulit untuk dicapai jika atmosfer hanya berupa meja dengan sekelompok kantong berdebu, dan seluruh permainan terdiri dari melempar dadu. Namun demikian, inilah tepatnya yang akan kami nyuarakan: sutera ada di sini, para pemain ada di sini,gemerisik dan gemerisik ke jeritan keras - seolah-olah kita tidak mengamati gambar di layar, tetapi benar-benar berinteraksi dengan benda fisik nyata. Mereka perlu disuarakan sepenuhnya, tetapi tidak mencolok - di seluruh naskah Anda akan mendengar hal yang sama seratus kali, sehingga suara tidak boleh muncul ke depan - hanya dengan lembut menaungi gameplay. Bagaimana cara mencapai hal ini secara kompeten? Saya tidak tahu, saya tidak istimewa dalam hal suara. Saya hanya bisa menyarankan Anda untuk bermain game sebanyak mungkin, memperhatikan dan memoles kekurangan yang mencolok (saran ini, omong-omong, berlaku tidak hanya untuk suara).Bagaimana cara mencapai hal ini secara kompeten? Saya tidak tahu, saya tidak istimewa dalam hal suara. Saya hanya bisa menyarankan Anda untuk bermain game sebanyak mungkin, memperhatikan dan memoles kekurangan yang mencolok (saran ini, omong-omong, berlaku tidak hanya untuk suara).Bagaimana cara mencapai hal ini secara kompeten? Saya tidak tahu, saya tidak istimewa dalam hal suara. Saya hanya bisa menyarankan Anda untuk bermain game sebanyak mungkin, memperhatikan dan memoles kekurangan yang mencolok (saran ini, omong-omong, berlaku tidak hanya untuk suara).Dengan teori, tampaknya, bereskan, sekarang saatnya untuk beralih ke praktik. Dan sebelum itu Anda perlu mengajukan pertanyaan: di mana, pada kenyataannya, untuk mengambil file game? Cara termudah dan paling pasti - untuk merekamnya sendiri dalam kualitas yang buruk, menggunakan mikrofon lama atau bahkan menggunakan telepon. Internet penuh dengan video tentang bagaimana membuka tutup nanas atau memecahkan es dengan sepatu bot dapat mencapai efek menghancurkan tulang dan tulang renyah. Jika Anda tidak asing dengan estetika surealis, Anda dapat menggunakan suara atau peralatan dapur Anda sendiri sebagai alat musik (ada contoh - dan bahkan yang sukses - di mana ini dilakukan). Atau Anda bisa mengunjungi freesound.orgdi mana seratus orang lainnya melakukan ini untuk Anda sejak lama. Hanya memperhatikan lisensi: banyak penulis sangat sensitif terhadap rekaman audio batuk keras atau koin yang dilemparkan ke lantai - Anda tidak ingin dengan tidak sengaja menggunakan hasil jerih payah mereka tanpa membayar pembuat asli atau tidak menyebut nama kreatifnya (kadang-kadang sangat aneh) di komentar.Seret file yang Anda suka dan letakkan di suatu tempat di classpath. Untuk mengidentifikasi mereka, kami akan menggunakan enumerasi, di mana setiap instance sesuai dengan satu efek suara. enum class Sound { TURN_START,
Karena metode mereproduksi suara akan bervariasi tergantung pada platform perangkat keras, kita dapat diabstraksi dari implementasi spesifik menggunakan antarmuka. Misalnya, ini: interface SoundPlayer { fun play(sound: Sound) }
Seperti antarmuka yang dibahas sebelumnya GameRenderer
dan GameInteractor
, implementasinya juga perlu diteruskan ke input ke instance kelas Game
. Sebagai permulaan, implementasi bisa seperti ini: class MuteSoundPlayer : SoundPlayer { override fun play(sound: Sound) {
Selanjutnya, kami akan mempertimbangkan implementasi yang lebih menarik, tetapi untuk sekarang mari kita bicara tentang musik.Seperti efek suara, ia memainkan peran besar dalam menciptakan suasana gim, dan dengan cara yang sama, gim yang luar biasa dapat dirusak oleh musik yang tidak pantas. Seperti suara, musik harus tidak mencolok, tidak muncul ke permukaan (kecuali jika diperlukan untuk efek artistik) dan cukup sesuai dengan tindakan di layar (jangan berharap bahwa seseorang serius diilhami dengan nasib utama yang disergap dan tanpa ampun membunuh utama Pahlawan, jika adegan kematian tragisnya akan disertai dengan musik kecil yang menyenangkan dari lagu anak-anak). Sangat sulit untuk mencapai hal ini, orang-orang yang terlatih khusus menangani masalah-masalah seperti itu (kami tidak terbiasa dengan mereka), tetapi kami, sebagai pemula jenius membangun kembali, juga dapat melakukan sesuatu. Misalnya, pergi ke suatu tempatfreemusicarchive.org atau soundcloud.com (atau YouTube) dan menemukan sesuatu yang mereka sukai. Untuk desktop, ambient adalah pilihan yang baik - musik yang tenang dan halus tanpa melodi yang diucapkan, cocok untuk menciptakan latar belakang. Double memperhatikan lisensi: bahkan musik gratis kadang-kadang ditulis oleh komposer berbakat yang layak, jika bukan hadiah uang, maka setidaknya pengakuan universal.Mari kita buat satu enumerasi lagi: enum class Music { SCENARIO_MUSIC_1, SCENARIO_MUSIC_2, SCENARIO_MUSIC_3, }
Demikian pula, kami mendefinisikan antarmuka dan implementasi standarnya. interface MusicPlayer { fun play(music: Music) fun stop() } class MuteMusicPlayer : MusicPlayer { override fun play(music: Music) {
Harap perhatikan bahwa dalam hal ini diperlukan dua metode: satu untuk memulai pemutaran, yang lain untuk menghentikannya. Sangat mungkin juga bahwa metode tambahan (jeda / melanjutkan, mundur, dll.) Akan berguna di masa depan, tetapi sejauh ini keduanya sudah cukup.Melewati referensi ke kelas pemain antar objek setiap kali mungkin tidak tampak seperti solusi yang sangat nyaman. Pada suatu waktu, kita hanya perlu satu pemain ekzepmlyar, jadi saya berani menyarankan untuk membuat semua yang diperlukan untuk memutar suara dan metode musik dalam sebuah objek yang terpisah dan membuatnya penyendiri (tunggal). Dengan demikian, subsistem audio yang bertanggung jawab selalu tersedia dari mana saja dalam aplikasi tanpa terus-menerus mengirimkan tautan ke instance yang sama. Ini akan terlihat seperti ini:Diagram kelas sistem pemutaran audio Kelas Audio
adalah singleton kami. Ini memberikan satu fasad tunggal untuk subsistem ... omong-omong, inilah fasad (fasad) - pola desain lain, yang dirancang secara menyeluruh dan berulang kali dideskripsikan (dengan contoh) di Internet Anda. Karena itu, setelah mendengar teriakan tidak puas dari barisan belakang, saya berhenti menjelaskan hal-hal yang sudah lama diketahui dan melanjutkan. Kode tersebut adalah: object Audio { private var soundPlayer: SoundPlayer = MuteSoundPlayer() private var musicPlayer: MusicPlayer = MuteMusicPlayer() fun init(soundPlayer: SoundPlayer, musicPlayer: MusicPlayer) { this.soundPlayer = soundPlayer this.musicPlayer = musicPlayer } fun playSound(sound: Sound) = this.soundPlayer.play(sound) fun playMusic(music: Music) = this.musicPlayer.play(music) fun stopMusic() = this.musicPlayer.stop() }
Cukup menyebutnya init()
sekali hanya di suatu tempat di awal (dengan menginisialisasi dengan objek yang diperlukan) dan di masa depan menggunakan metode yang mudah, benar-benar lupa tentang detail implementasi. Bahkan jika Anda tidak, jangan khawatir, sistem akan mati - objek akan diinisialisasi oleh kelas default.Itu saja.
Masih berurusan dengan pemutaran yang sebenarnya. Adapun untuk memainkan suara (atau, seperti orang pintar katakan, sampel ), Java memiliki kelas AudioSystem
dan antarmuka yang nyaman Clip
. Yang kita butuhkan hanyalah mengatur path ke file audio dengan benar (ingat, yang terletak di classpath kita, ingat?): import javax.sound.sampled.AudioSystem class BasicSoundPlayer : SoundPlayer { private fun pathToFile(sound: Sound) = "/sound/${sound.toString().toLowerCase()}.wav" override fun play(sound: Sound) { val url = javaClass.getResource(pathToFile(sound)) val audioIn = AudioSystem.getAudioInputStream(url) val clip = AudioSystem.getClip() clip.open(audioIn) clip.start() } }
Metode ini open()
dapat membuangnya IOException
(terutama jika dia tidak menyukai format file karena suatu alasan - dalam hal ini saya sarankan membuka file dalam editor audio dan menyimpannya kembali), jadi alangkah baiknya untuk membungkusnya dalam satu blok try-catch
, tetapi pada awalnya kami tidak akan melakukannya sehingga aplikasinya keras. jatuh setiap kali dengan masalah dengan suara."Aku bahkan tidak tahu harus berkata apa ..."Segalanya jauh lebih buruk dengan musik. Sejauh yang saya tahu, tidak ada cara standar untuk memutar file musik (misalnya, dalam format mp3) di Jawa, jadi Anda harus menggunakan perpustakaan pihak ketiga (ada lusinan yang berbeda). Ringan apa pun dengan fungsionalitas minimal cocok untuk kita, misalnya, JLayer yang agak populer . Tambahkan tergantung: <dependencies> <dependency> <groupId>com.googlecode.soundlibs</groupId> <artifactId>jlayer</artifactId> <version>1.0.1.4</version> <scope>compile</scope> </dependency> </dependencies>
Dan kami menerapkan pemain kami dengan bantuannya. class BasicMusicPlayer : MusicPlayer { private var currentMusic: Music? = null private var thread: PlayerThread? = null private fun pathToFile(music: Music) = "/music/${music.toString().toLowerCase()}.mp3" override fun play(music: Music) { if (currentMusic == music) { return } currentMusic = music thread?.finish() Thread.yield() thread = PlayerThread(pathToFile(music)) thread?.start() } override fun stop() { currentMusic = null thread?.finish() }
Pertama, pustaka ini melakukan pemutaran secara serempak, memblokir aliran utama hingga akhir file tercapai. Oleh karena itu, kita harus mengimplementasikan utas terpisah ( PlayerThread
), dan menjadikannya "opsional" (daemon), sehingga tidak akan mengganggu aplikasi untuk mengakhiri lebih awal. Kedua, pengidentifikasi file musik yang sedang diputar ( currentMusic
) disimpan dalam kode pemutar . Jika perintah kedua tiba-tiba datang untuk memainkannya, kami tidak akan memulai pemutaran dari awal. Ketiga, setelah mencapai akhir file musik, pemutarannya akan mulai lagi - dan seterusnya hingga streaming dihentikan secara eksplisit oleh perintahfinish()
(atau sampai utas lainnya selesai, seperti yang telah disebutkan). Keempat, meskipun kode di atas penuh dengan flag dan perintah yang tampaknya tidak perlu, itu sepenuhnya debugged dan diuji - pemain bekerja seperti yang diharapkan, tidak memperlambat sistem, tidak mengganggu tiba-tiba di tengah jalan, tidak menyebabkan kebocoran memori, tidak mengandung objek yang dimodifikasi secara genetik, bersinar kesegaran dan kemurnian. Ambillah dan gunakan dengan berani dalam proyek Anda.Langkah Dua Belas. Lokalisasi
Game kami hampir siap, tetapi tidak ada yang akan memainkannya. Mengapa
"Tidak ada Rusia! .. Tidak ada Rusia! .. Tambah bahasa Rusia! .. Dikembangkan oleh anjing!"Buka halaman game cerita menarik apa saja (terutama ponsel) di situs web toko dan baca ulasannya. Apakah mereka akan mulai memuji grafis yang luar biasa, gambar tangan? Atau kagumi suara atmosfer? Atau diskusikan kisah menarik yang membuat ketagihan sejak menit pertama dan tidak melepaskannya sampai akhir?Tidak.
"Pemain" yang tidak puas akan menginstruksikan banyak unit dan umumnya menghapus permainan. Dan kemudian mereka juga akan membutuhkan uang kembali - dan semua ini karena satu alasan sederhana. Ya, Anda lupa menerjemahkan karya Anda ke dalam 95 bahasa dunia. Atau lebih tepatnya, orang yang operatornya berteriak paling keras. Dan itu dia! Apakah kamu mengerti
Kerja keras berbulan-bulan, malam tanpa tidur yang lama, gangguan saraf yang konstan - semua ini adalah hamster di bawah ekor. Anda kehilangan banyak pemain dan ini tidak bisa diperbaiki.Jadi pikirkan dulu. Tentukan target audiens Anda, pilih beberapa bahasa utama, pesan layanan terjemahan ... secara umum, lakukan semua yang dijelaskan orang lain lebih dari satu kali dalam artikel tematik (lebih pintar dari saya). Kami akan fokus pada sisi teknis masalah ini dan berbicara tentang cara melokalkan produk kami tanpa rasa sakit.Pertama kita masuk ke templat. Ingat, sebelum nama dan deskripsi disimpan sesederhana itu String
? Sekarang tidak akan berhasil. Selain bahasa default, Anda juga perlu menyediakan terjemahan ke semua bahasa yang Anda rencanakan untuk didukung. Misalnya, seperti ini: class TestEnemyTemplate : EnemyTemplate { override val name = "Test enemy" override val description = "Some enemy standing in your way." override val nameLocalizations = mapOf( "ru" to " -", "ar" to "بعض العدو", "iw" to "איזה אויב", "zh" to "一些敵人", "ua" to "і " ) override val descriptionLocalizations = mapOf( "ru" to " - .", "ar" to "وصف العدو", "iw" to "תיאור האויב", "zh" to "一些敵人的描述", "ua" to " ї і ." ) override val traits = listOf<Trait>() }
Untuk templat, pendekatan ini cukup cocok. Jika Anda tidak ingin menentukan terjemahan untuk bahasa apa pun, maka Anda tidak perlu - selalu ada nilai default. Namun, pada objek akhir, saya tidak ingin menjangkau garis ke beberapa bidang yang berbeda. Karena itu, kita akan meninggalkan satu, tetapi ganti tipenya. class LocalizedString(defaultValue: String, localizations: Map<String, String>) { private val default: String = defaultValue private val values: Map<String, String> = localizations.toMap() operator fun get(lang: String) = values.getOrDefault(lang, default) override fun equals(other: Any?) = when { this === other -> true other !is LocalizedString -> false else -> default == other.default } override fun hashCode(): Int { return default.hashCode() } }
Dan perbaiki kode generator yang sesuai. fun generateEnemy(template: EnemyTemplate) = Enemy().apply { name = LocalizedString(template.name, template.nameLocalizations) description = LocalizedString(template.description, template.descriptionLocalizations) template.traits.forEach { addTrait(it) } }
Secara alami, pendekatan yang sama harus diterapkan pada tipe templat yang tersisa. Ketika perubahan sudah siap, mereka dapat digunakan tanpa kesulitan. val language = Locale.getDefault().language val enemyName = enemy.name[language]
Dalam contoh kami, kami telah menyediakan versi lokalisasi yang disederhanakan, di mana hanya bahasa yang diperhitungkan. Secara umum, objek kelas Locale
juga menentukan negara dan wilayah. Jika ini penting dalam aplikasi Anda, maka milik Anda LocalizedString
akan terlihat sedikit berbeda, tetapi kami tetap senang dengan itu.Kami berurusan dengan templat, masih untuk melokalkan jalur layanan yang digunakan dalam aplikasi kami. Untungnya, ResourceBundle
sudah mengandung semua mekanisme yang diperlukan. Anda hanya perlu menyiapkan file dengan terjemahan dan mengubah cara pengunduhannya. # Game status messages choose_dice_perform_check= : end_of_turn_discard_extra= : : end_of_turn_discard_optional= : : choose_action_before_exploration=, : choose_action_after_exploration= . ? encounter_physical= . . encounter_somatic= . . encounter_mental= . . encounter_verbal= . . encounter_divine= . : die_acquire_success= ! die_acquire_failure= . game_loss_out_of_time= # Die types physical= somatic= mental= verbal= divine= ally= wound= enemy= villain= obstacle= # Hero types and descriptions brawler= hunter= # Various labels avg= bag= bag_size= class= closed= discard= empty= encountered= fail= hand= heros_turn= %s max= min= perform_check= : pile= received_new_die= result= success= sum= time= total= # Action names and descriptions action_confirm_key=ENTER action_confirm_name= action_cancel_key=ESC action_cancel_name= action_explore_location_key=E action_explore_location_name= action_finish_turn_key=F action_finish_turn_name= action_hide_key=H action_bag_name= action_discard_key=D action_discard_name= action_acquire_key=A action_acquire_name= action_leave_key=L action_leave_name= action_forfeit_key=F action_forfeit_name=
Saya tidak akan mengatakan sebagai catatan: menulis frasa dalam bahasa Rusia jauh lebih sulit daripada dalam bahasa Inggris. Jika ada persyaratan untuk menggunakan kata benda dalam kasus definitif atau untuk melepaskan diri dari jenis kelamin (dan persyaratan seperti itu akan berlaku), Anda harus banyak berkeringat sebelum mendapatkan hasil yang, pertama, memenuhi persyaratan, dan kedua, tidak terlihat seperti terjemahan mekanis yang dibuat oleh cyborg. dengan otak ayam. Juga perhatikan bahwa kami tidak mengubah tombol aksi - seperti sebelumnya, karakter yang sama akan digunakan untuk mengeksekusi yang terakhir seperti dalam bahasa Inggris (yang, omong-omong, tidak akan bekerja di tata letak keyboard selain yang Latin, tapi ini bukan urusan kami - untuk saat ini, biarkan apa adanya). class PropertiesStringLoader(locale: Locale) : StringLoader { private val properties = ResourceBundle.getBundle("text.strings", locale) override fun loadString(key: String) = properties.getString(key) ?: "" }
.
Seperti yang telah disebutkan, ResourceBundle
dia sendiri akan mengambil tanggung jawab untuk menemukan di antara file pelokalan yang paling cocok dengan lokal saat ini. Dan jika dia tidak menemukannya, dia akan mengambil file default ( string.properties
). Dan semuanya akan baik-baik saja ...Ya! Itu dia!, Unicode
.properties
Java 9. ISO-8859-1 —
ResourceBundle
. , , — . Unicode- — , , :
'\uXXXX'
. , , Java
native2ascii , . :
# Game status messages choose_dice_perform_check=\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u043a\u0443\u0431\u0438\u043a\u0438 \u0434\u043b\u044f \u043f\u0440\u043e\u0445\u043e\u0436\u0434\u0435\u043d\u0438\u044f \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0438: end_of_turn_discard_extra=\u041a\u041e\u041d\u0415\u0426 \u0425\u041e\u0414\u0410: \u0421\u0431\u0440\u043e\u0441\u044c\u0442\u0435 \u043b\u0438\u0448\u043d\u0438\u0435 \u043a\u0443\u0431\u0438\u043a\u0438: end_of_turn_discard_optional=\u041a\u041e\u041d\u0415\u0426 \u0425\u041e\u0414\u0410: \u0421\u0431\u0440\u043e\u0441\u044c\u0442\u0435 \u043a\u0443\u0431\u0438\u043a\u0438 \u043f\u043e \u0436\u0435\u043b\u0430\u043d\u0438\u044e: choose_action_before_exploration=\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435, \u0447\u0442\u043e \u0434\u0435\u043b\u0430\u0442\u044c: choose_action_after_exploration=\u0418\u0441\u0441\u043b\u0435\u0434\u043e\u0432\u0430\u043d\u0438\u0435 \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u043e. \u0427\u0442\u043e \u0434\u0435\u043b\u0430\u0442\u044c \u0434\u0430\u043b\u044c\u0448\u0435? encounter_physical=\u0412\u0441\u0442\u0440\u0435\u0447\u0435\u043d \u0424\u0418\u0417\u0418\u0427\u0415\u0421\u041a\u0418\u0419 \u043a\u0443\u0431\u0438\u043a. \u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u043f\u0440\u043e\u0439\u0442\u0438 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0443. encounter_somatic=\u0412\u0441\u0442\u0440\u0435\u0447\u0435\u043d \u0421\u041e\u041c\u0410\u0422\u0418\u0427\u0415\u0421\u041a\u0418\u0419 \u043a\u0443\u0431\u0438\u043a. \u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u043f\u0440\u043e\u0439\u0442\u0438 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0443. encounter_mental=\u0412\u0441\u0442\u0440\u0435\u0447\u0435\u043d \u041c\u0415\u041d\u0422\u0410\u041b\u042c\u041d\u042b\u0419 \u043a\u0443\u0431\u0438\u043a. \u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u043f\u0440\u043e\u0439\u0442\u0438 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0443. encounter_verbal=\u0412\u0441\u0442\u0440\u0435\u0447\u0435\u043d \u0412\u0415\u0420\u0411\u0410\u041b\u042c\u041d\u042b\u0419 \u043a\u0443\u0431\u0438\u043a. \u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u043f\u0440\u043e\u0439\u0442\u0438 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0443. encounter_divine=\u0412\u0441\u0442\u0440\u0435\u0447\u0435\u043d \u0411\u041e\u0416\u0415\u0421\u0422\u0412\u0415\u041d\u041d\u042b\u0419 \u043a\u0443\u0431\u0438\u043a. \u041c\u043e\u0436\u043d\u043e \u0432\u0437\u044f\u0442\u044c \u0431\u0435\u0437 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0438: die_acquire_success=\u0412\u044b \u043f\u043e\u043b\u0443\u0447\u0438\u043b\u0438 \u043d\u043e\u0432\u044b\u0439 \u043a\u0443\u0431\u0438\u043a! die_acquire_failure=\u0412\u0430\u043c \u043d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u043a\u0443\u0431\u0438\u043a. game_loss_out_of_time=\u0423 \u0432\u0430\u0441 \u0437\u0430\u043a\u043e\u043d\u0447\u0438\u043b\u043e\u0441\u044c \u0432\u0440\u0435\u043c\u044f
. — . — . , IDE ( ) « », — - ( ), IDE, .
, .
getBundle()
, , ,
ResourceBundle.Control
— - .
class PropertiesStringLoader(locale: Locale) : StringLoader { private val properties = ResourceBundle.getBundle( "text.strings", locale, Utf8ResourceBundleControl()) override fun loadString(key: String) = properties.getString(key) ?: "" }
, , :
class Utf8ResourceBundleControl : ResourceBundle.Control() { @Throws(IllegalAccessException::class, InstantiationException::class, IOException::class) override fun newBundle(baseName: String, locale: Locale, format: String, loader: ClassLoader, reload: Boolean): ResourceBundle? { val bundleName = toBundleName(baseName, locale) return when (format) { "java.class" -> super.newBundle(baseName, locale, format, loader, reload) "java.properties" -> with((if ("://" in bundleName) null else toResourceName(bundleName, "properties")) ?: return null) { when { reload -> reload(this, loader) else -> loader.getResourceAsStream(this) }?.let { stream -> InputStreamReader(stream, "UTF-8").use { r -> PropertyResourceBundle(r) } } } else -> throw IllegalArgumentException("Unknown format: $format") } } @Throws(IOException::class) private fun reload(resourceName: String, classLoader: ClassLoader): InputStream { classLoader.getResource(resourceName)?.let { url -> url.openConnection().let { connection -> connection.useCaches = false return connection.getInputStream() } } throw IOException("Unable to load data!") } }
, … , ( ) — ( Kotlin ). — ,
.properties
UTF-8 - .
Untuk menguji operasi aplikasi dalam berbagai bahasa, tidak perlu mengubah pengaturan sistem operasi - cukup tentukan bahasa yang diperlukan saat memulai JRE: java -Duser.language=ru -jar path_to_project\Dice\target\dice-1.0-jar-with-dependencies.jar
Jika Anda masih bekerja di Windows, perkirakan ada masalah, Windows (cmd.exe) 437 ( DOSLatinUS), — . , UTF-8 , :
chcp 65001
Java , , . :
java -Dfile.encoding=UTF-8 -Duser.language=ru -jar path_to_project\Dice\target\dice-1.0-jar-with-dependencies.jar
, , Unicode- (, Lucida Console)
Setelah semua petualangan kami yang mengasyikkan, hasilnya dapat dengan bangga ditunjukkan kepada masyarakat umum dan dengan keras menyatakan: "Kami bukan anjing!"Dan itu bagus.Langkah Tiga Belas Menyatukan semuanya
Pembaca yang penuh perhatian harus memperhatikan bahwa saya menyebutkan nama-nama paket khusus hanya sekali dan tidak pernah kembali kepada mereka. Pertama, setiap pengembang memiliki pertimbangan sendiri mengenai kelas mana yang harus ditempatkan dalam paket mana. Kedua, saat Anda mengerjakan proyek, dengan penambahan lebih banyak kelas baru, pemikiran Anda akan berubah. Ketiga, mengubah struktur aplikasi itu sederhana dan murah (dan sistem kontrol versi modern akan mendeteksi migrasi, sehingga Anda tidak akan kehilangan sejarah), jadi jangan ragu untuk mengubah nama kelas, paket, metode, dan variabel - jangan lupa hanya memperbarui dokumentasi (Anda tetap menyimpannya) benar?).Dan yang tersisa bagi kita adalah mengumpulkan dan meluncurkan proyek kita. Seperti yang Anda ingat, main()
kami telah membuat metode , sekarang kami akan mengisinya dengan konten. Kami akan membutuhkan:
- skrip dan medan;
- Pahlawan
- implementasi antarmuka
GameInteractor
; - implementasi antarmuka
GameRenderer
dan StringLoader
; - implementasi antarmuka
SoundPlayer
dan MusicPlayer
; - objek kelas
Game
; - sebotol sampanye.
Ayo pergi!
fun main(args: Array<String>) { Audio.init(BasicSoundPlayer(), BasicMusicPlayer()) val loader = PropertiesStringLoader(Locale.getDefault()) val renderer = ConsoleGameRenderer(loader) val interactor = ConsoleGameInteractor() val template = TestScenarioTemplate() val scenario = generateScenario(template, 1) val locations = generateLocations(template, 1, heroes.size) val heroes = listOf( generateHero(Hero.Type.BRAWLER, "Brawler"), generateHero(Hero.Type.HUNTER, "Hunter") ) val game = Game(renderer, interactor, scenario, locations, heroes) game.start() }
Kami meluncurkan dan menikmati prototipe kerja pertama. Itu dia.Langkah empat belas. Saldo game
Ummm ...Langkah lima belas. Tes
Sekarang sebagian besar kode untuk prototipe kerja pertama telah ditulis, alangkah baiknya menambahkan beberapa tes unit ..."Bagaimana? Baru saja? Ya, tes harus ditulis di awal, dan kemudian kode! "Banyak pembaca dengan benar memperhatikan bahwa tes unit penulisan harus mendahului pengembangan kode kerja ( TDD)dan metodologi modis lainnya). Yang lain akan marah: tidak ada yang bisa dibodohi orang dengan tes mereka, bahkan jika setidaknya mereka mulai mengembangkan sesuatu, jika tidak semua motivasi akan hilang. Beberapa orang lain akan merangkak keluar dari celah di alas tiang dan dengan takut mengatakan: "Saya tidak mengerti mengapa tes ini diperlukan - semuanya bekerja untuk saya" ... Kemudian mereka akan didorong ke wajah dengan sepatu bot dan dengan cepat didorong ke belakang. Saya tidak akan memulai untuk memulai konfrontasi ideologis (mereka sudah penuh dengan mereka di Internet), dan karena itu saya setuju sebagian dengan semua orang. Ya, tes terkadang bermanfaat (terutama dalam kode yang sering berubah atau dikaitkan dengan perhitungan yang rumit), ya, pengujian unit tidak cocok untuk semua kode (misalnya, tidak mencakup interaksi dengan pengguna atau sistem eksternal), ya, ada lebih dari pengujian unit banyak jenis lainnya (well, setidaknya lima diberi nama),dan ya, kami tidak akan fokus pada tes menulis - artikel kami adalah tentang sesuatu yang lain.Katakan saja: banyak programmer (terutama pemula) mengabaikan tes. Banyak yang membenarkan diri mereka dengan mengatakan bahwa fungsionalitas aplikasi mereka tidak tercakup oleh tes. Misalnya, jauh lebih mudah untuk meluncurkan aplikasi dan melihat apakah semuanya sesuai dengan tampilan dan interaksi, daripada pagar konstruksi rumit dengan partisipasi kerangka kerja khusus untuk menguji antarmuka pengguna (dan ada yang seperti itu). Dan saya akan memberi tahu Anda ketika saya sedang mengimplementasikan antarmuka Renderer
- saya melakukan hal itu. Namun, ada beberapa metode di antara kode kami yang konsep pengujian unitnya bagus.Misalnya, generator. Dan itu saja. Ini adalah kotak hitam yang ideal: templat adalah input, dan objek dari dunia game diperoleh pada output. Ada sesuatu yang terjadi di dalam, tetapi kita perlu mengujinya. Misalnya, seperti ini: public class DieGeneratorTest { @Test public void testGetMaxLevel() { assertEquals("Max level should be 3", 3, DieGeneratorKt.getMaxLevel()); } @Test public void testDieGenerationSize() { DieTypeFilter filter = new SingleDieTypeFilter(Die.Type.ALLY); List<? extends List<Integer>> allowedSizes = Arrays.asList( null, Arrays.asList(4, 6, 8), Arrays.asList(4, 6, 8, 10), Arrays.asList(6, 8, 10, 12) ); IntStream.rangeClosed(1, 3).forEach(level -> { for (int i = 0; i < 10; i++) { int size = DieGeneratorKt.generateDie(filter, level).getSize(); assertTrue("Incorrect level of die generated: " + size, allowedSizes.get(level).contains(size)); assertTrue("Incorrect die size: " + size, size >= 4); assertTrue("Incorrect die size: " + size, size <= 12); assertTrue("Incorrect die size: " + size, size % 2 == 0); } }); } @Test public void testDieGenerationType() { List<Die.Type> allowedTypes1 = Arrays.asList(Die.Type.PHYSICAL); List<Die.Type> allowedTypes2 = Arrays.asList(Die.Type.PHYSICAL, Die.Type.SOMATIC, Die.Type.MENTAL, Die.Type.VERBAL); List<Die.Type> allowedTypes3 = Arrays.asList(Die.Type.ALLY, Die.Type.VILLAIN, Die.Type.ENEMY); for (int i = 0; i < 10; i++) { Die.Type type1 = DieGeneratorKt.generateDie(new SingleDieTypeFilter(Die.Type.PHYSICAL), 1).getType(); assertTrue("Incorrect die type: " + type1, allowedTypes1.contains(type1)); Die.Type type2 = DieGeneratorKt.generateDie(new StatsDieTypeFilter(), 1).getType(); assertTrue("Incorrect die type: " + type2, allowedTypes2.contains(type2)); Die.Type type3 = DieGeneratorKt.generateDie(new MultipleDieTypeFilter(Die.Type.ALLY, Die.Type.VILLAIN, Die.Type.ENEMY), 1).getType(); assertTrue("Incorrect die type: " + type3, allowedTypes3.contains(type3)); } } }
Atau lebih:
public class BagGeneratorTest { @Test public void testGenerateBag() { BagTemplate template1 = new BagTemplate(); template1.addPlan(0, 10, new SingleDieTypeFilter(Die.Type.PHYSICAL)); template1.addPlan(5, 5, new SingleDieTypeFilter(Die.Type.SOMATIC)); template1.setFixedDieCount(null); BagTemplate template2 = new BagTemplate(); template2.addPlan(10, 10, new SingleDieTypeFilter(Die.Type.DIVINE)); template2.setFixedDieCount(5); BagTemplate template3 = new BagTemplate(); template3.addPlan(10, 10, new SingleDieTypeFilter(Die.Type.ALLY)); template3.setFixedDieCount(50); for (int i = 0; i < 10; i++) { Bag bag1 = BagGeneratorKt.generateBag(template1, 1); assertTrue("Incorrect bag size: " + bag1.getSize(), bag1.getSize() >= 5 && bag1.getSize() <= 15); assertEquals("Incorrect number of SOMATIC dice", 5, bag1.examine().stream().filter(d -> d.getType() == Die.Type.SOMATIC).count()); Bag bag2 = BagGeneratorKt.generateBag(template2, 1); assertEquals("Incorrect bag size", 5, bag2.getSize()); Bag bag3 = BagGeneratorKt.generateBag(template3, 1); assertEquals("Incorrect bag size", 50, bag3.getSize()); List<Die.Type> dieTypes3 = bag3.examine().stream().map(Die::getType).distinct().collect(Collectors.toList()); assertEquals("Incorrect die types", 1, dieTypes3.size()); assertEquals("Incorrect die types", Die.Type.ALLY, dieTypes3.get(0)); } } }
Atau bahkan seperti ini: public class LocationGeneratorTest { private void testLocationGeneration(String name, LocationTemplate template) { System.out.println("Template: " + template.getName()); assertEquals("Incorrect template type", name, template.getName()); IntStream.rangeClosed(1, 3).forEach(level -> { Location location = LocationGeneratorKt.generateLocation(template, level); assertEquals("Incorrect location type", name, location.getName().get("")); assertTrue("Location not open by default", location.isOpen()); int closingDifficulty = location.getClosingDifficulty(); assertTrue("Closing difficulty too small", closingDifficulty > 0); assertEquals("Incorrect closing difficulty", closingDifficulty, template.getBasicClosingDifficulty() + level * 2); Bag bag = location.getBag(); assertNotNull("Bag is null", bag); assertTrue("Bag is empty", location.getBag().getSize() > 0); Deck<Enemy> enemies = location.getEnemies(); assertNotNull("Enemies are null", enemies); assertEquals("Incorrect enemy threat count", enemies.getSize(), template.getEnemyCardsCount()); if (bag.drawOfType(Die.Type.ENEMY) != null) { assertTrue("Enemy cards not specified", enemies.getSize() > 0); } Deck<Obstacle> obstacles = location.getObstacles(); assertNotNull("Obstacles are null", obstacles); assertEquals("Incorrect obstacle threat count", obstacles.getSize(), template.getObstacleCardsCount()); List<SpecialRule> specialRules = location.getSpecialRules(); assertNotNull("SpecialRules are null", specialRules); }); } @Test public void testGenerateLocation() { testLocationGeneration("Test Location", new TestLocationTemplate()); testLocationGeneration("Test Location 2", new TestLocationTemplate2()); } }
"Berhenti, berhenti, berhenti!" Apa ini Jawa ??? "Kamu mendapatkannya. Selain itu, ada baiknya untuk menulis tes seperti itu di awal, sebelum Anda mulai mengimplementasikan generator itu sendiri. Tentu saja, kode yang diuji cukup sederhana dan kemungkinan besar metode ini akan bekerja pertama kali dan tanpa tes apa pun, tetapi menulis tes sekali Anda akan melupakannya selamanya akan melindungi diri Anda dari masalah yang mungkin terjadi di masa depan (solusi yang membutuhkan banyak waktu, terutama ketika dari saat pengembangan lima tahun telah berlalu dan Anda sudah lupa bagaimana segala sesuatu di dalam metode ini bekerja di sana). Dan jika tiba-tiba suatu hari proyek Anda berhenti mengumpulkan karena tes gagal, Anda pasti akan tahu alasannya: persyaratan untuk sistem telah berubah dan tes lama Anda tidak lagi memuaskan mereka (apa yang Anda pikirkan?).Dan satu hal lagi. Ingat kelasHandMaskRule
dan ahli warisnya? Sekarang bayangkan bahwa pada titik tertentu untuk menggunakan keterampilan pahlawan perlu mengambil tiga dadu dari tangannya, dan jenis dadu ini ditempati oleh pembatasan yang ketat (misalnya, "dadu pertama harus biru, hijau atau putih, yang kedua - kuning, putih atau biru, dan yang ketiga - biru atau ungu "- apakah Anda merasakan kesulitan?). Bagaimana cara mendekati implementasi kelas? Baiklah ... sebagai permulaan, Anda dapat memutuskan parameter input dan output. Jelas, Anda membutuhkan kelas untuk menerima tiga array (atau set), yang masing-masing berisi tipe yang valid untuk, masing-masing, kubus pertama, kedua dan ketiga. Lalu apa? Busting? Rekursi? Bagaimana jika saya melewatkan sesuatu? Buat pintu masuk yang dalam. Sekarang tunda penerapan metode kelas dan tulis tes - karena persyaratannya sederhana, dapat dimengerti, dan dapat diformalkan dengan baik.Dan lebih baik menulis beberapa tes ... Tapi kami akan mempertimbangkan satu, di sini misalnya misalnya: public class TripleDieHandMaskRuleTest { private Hand hand; @Before public void init() { hand = new Hand(10); hand.addDie(new Die(Die.Type.PHYSICAL, 4));
Ini melelahkan, tetapi tidak sebanyak kelihatannya, sampai Anda mulai (pada titik tertentu bahkan menjadi menyenangkan). Tetapi setelah menulis ujian seperti itu (dan beberapa lainnya, untuk berbagai kesempatan), Anda tiba-tiba akan merasa tenang dan percaya diri. Sekarang, tidak ada kesalahan ketik kecil akan merusak metode Anda dan menyebabkan kejutan yang tidak menyenangkan yang jauh lebih sulit untuk diuji secara manual. Sedikit demi sedikit, perlahan-lahan, kita mulai menerapkan metode kelas yang diperlukan. Dan pada akhirnya kami menjalankan tes untuk memastikan bahwa di suatu tempat kami membuat kesalahan. Temukan tempat masalahnya dan tulis ulang. Ulangi sampai selesai. class TripleDieHandMaskRule( hand: Hand, types1: Array<Die.Type>, types2: Array<Die.Type>, types3: Array<Die.Type>) : HandMaskRule(hand) { private val types1 = types1.toSet() private val types2 = types2.toSet() private val types3 = types3.toSet() override fun checkMask(mask: HandMask): Boolean { if (mask.positionCount + mask.allyPositionCount != 3) { return false } return getCheckedDice(mask).asSequence() .filter { it.type in types1 } .any { d1 -> getCheckedDice(mask) .filter { d2 -> d2 !== d1 } .filter { it.type in types2 } .any { d2 -> getCheckedDice(mask) .filter { d3 -> d3 !== d1 } .filter { d3 -> d3 !== d2 } .any { it.type in types3 } } } } override fun isPositionActive(mask: HandMask, position: Int): Boolean { if (mask.checkPosition(position)) { return true } val die = hand.dieAt(position) ?: return false return when (mask.positionCount + mask.allyPositionCount) { 0 -> die.type in types1 || die.type in types2 || die.type in types3 1 -> with(getCheckedDice(mask).first()) { (this.type in types1 && (die.type in types2 || die.type in types3)) || (this.type in types2 && (die.type in types1 || die.type in types3)) || (this.type in types3 && (die.type in types1 || die.type in types2)) } 2-> with(getCheckedDice(mask)) { val d1 = this[0] val d2 = this[1] (d1.type in types1 && d2.type in types2 && die.type in types3) || (d2.type in types1 && d1.type in types2 && die.type in types3) || (d1.type in types1 && d2.type in types3 && die.type in types2) || (d2.type in types1 && d1.type in types3 && die.type in types2) || (d1.type in types2 && d2.type in types3 && die.type in types1) || (d2.type in types2 && d1.type in types3 && die.type in types1) } 3 -> false else -> false } } override fun isAllyPositionActive(mask: HandMask, position: Int): Boolean { if (mask.checkAllyPosition(position)) { return true } if (hand.allyDieAt(position) == null) { return false } return when (mask.positionCount + mask.allyPositionCount) { 0 -> ALLY in types1 || ALLY in types2 || ALLY in types3 1 -> with(getCheckedDice(mask).first()) { (this.type in types1 && (ALLY in types2 || ALLY in types3)) || (this.type in types2 && (ALLY in types1 || ALLY in types3)) || (this.type in types3 && (ALLY in types1 || ALLY in types2)) } 2-> with(getCheckedDice(mask)) { val d1 = this[0] val d2 = this[1] (d1.type in types1 && d2.type in types2 && ALLY in types3) || (d2.type in types1 && d1.type in types2 && ALLY in types3) || (d1.type in types1 && d2.type in types3 && ALLY in types2) || (d2.type in types1 && d1.type in types3 && ALLY in types2) || (d1.type in types2 && d2.type in types3 && ALLY in types1) || (d2.type in types2 && d1.type in types3 && ALLY in types1) } 3 -> false else -> false } } }
Jika Anda memiliki gagasan tentang cara menerapkan fungsi seperti itu dengan lebih mudah, Anda dapat mengomentari. Dan saya sangat senang bahwa saya cukup pintar untuk mulai menerapkan kelas ini dengan menulis ujian."Dan aku <...> juga <...> sangat <...> senang <...>. Masuk! <...> kembali! <...> ke celah! "Langkah enam belas. Modularitas
Seperti yang diharapkan, anak-anak yang sudah dewasa tidak dapat berada di bawah naungan orang tua mereka sepanjang hidup mereka - cepat atau lambat mereka harus memilih jalan mereka sendiri dan dengan berani mengikuti jalan itu, mengatasi kesulitan dan gangguan. Jadi komponen yang kami kembangkan menjadi matang sehingga menjadi sempit di bawah satu atap. Waktunya telah tiba untuk membaginya menjadi beberapa bagian.Kita dihadapkan dengan tugas yang agak sepele. Penting untuk memecah semua kelas yang dibuat sejauh ini menjadi tiga kelompok:- fungsionalitas dasar: modul, mesin game, antarmuka konektor dan implementasi platform-independen ( inti );
- templat skenario, medan, musuh, dan rintangan - komponen yang disebut "petualangan" ( adventure );
- implementasi spesifik antarmuka khusus untuk platform tertentu: dalam kasus kami, aplikasi konsol ( cli ).
Hasil dari pemisahan ini pada akhirnya akan terlihat seperti diagram berikut:Seperti para aktor di akhir pertunjukan, pahlawan kita hari ini memasuki kembali adegan dengan kekuatan penuh Buat proyek tambahan dan transfer kelas yang sesuai. Dan kita hanya perlu mengkonfigurasi dengan benar interaksi proyek di antara mereka sendiri. ProyekintiProyek ini adalah mesin murni. Semua kelas spesifik ditransfer ke proyek lain - hanya fungsi dasar, inti, tetap. Perpustakaan jika Anda mau. Tidak ada lagi kelas peluncuran, bahkan tidak ada kebutuhan untuk membangun sebuah paket. Sidang dari proyek ini akan di-host di repositori Maven lokal (lebih lanjut tentang itu nanti) dan digunakan oleh proyek lain sebagai dependensi.File tersebut pom.xml
adalah sebagai berikut: <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>my.company</groupId> <artifactId>dice-core</artifactId> <version>1.0</version> <packaging>jar</packaging> <dependencies> <dependency> <groupId>org.jetbrains.kotlin</groupId> <artifactId>kotlin-stdlib</artifactId> <version>${kotlin.version}</version> </dependency> <dependency> <groupId>junit</groupId> <artifactId>junit-dep</artifactId> <version>4.8.2</version> <scope>test</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.jetbrains.kotlin</groupId> </plugin> </plugins> </build> <properties> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <maven.compiler.source>1.8</maven.compiler.source> <maven.compiler.target>1.8</maven.compiler.target> <kotlin.version>1.3.20</kotlin.version> <kotlin.compiler.incremental>true</kotlin.compiler.incremental> </properties> </project>
Mulai sekarang kami akan mengumpulkannya seperti ini: mvn -f "path_to_project/DiceCore/pom.xml" install
Proyek CliInilah titik masuk ke aplikasi - dengan proyek inilah pengguna akhir akan berinteraksi. Kernel digunakan sebagai dependensi. Karena dalam contoh kami kami bekerja dengan konsol, proyek akan berisi kelas yang diperlukan untuk bekerja dengannya (jika kami tiba-tiba ingin memulai permainan dengan pembuat kopi, kami cukup mengganti proyek ini dengan yang serupa dengan implementasi yang sesuai). Kami akan segera menambahkan sumber daya (baris, file audio, dll.). Ketergantungan pada perpustakaan eksternal akan ditransfer kefile pom.xml
: <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>my.company</groupId> <artifactId>dice-cli</artifactId> <version>1.0</version> <packaging>jar</packaging> <dependencies> <dependency> <groupId>org.jetbrains.kotlin</groupId> <artifactId>kotlin-stdlib</artifactId> <version>${kotlin.version}</version> </dependency> <dependency> <groupId>my.company</groupId> <artifactId>dice-core</artifactId> <version>1.0</version> <scope>compile</scope> </dependency> <dependency> <groupId>org.fusesource.jansi</groupId> <artifactId>jansi</artifactId> <version>1.17.1</version> <scope>compile</scope> </dependency> <dependency> <groupId>jline</groupId> <artifactId>jline</artifactId> <version>2.14.6</version> <scope>compile</scope> </dependency> <dependency> <groupId>com.googlecode.soundlibs</groupId> <artifactId>jlayer</artifactId> <version>1.0.1.4</version> <scope>compile</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.jetbrains.kotlin</groupId> </plugin> <plugin> <artifactId>maven-assembly-plugin</artifactId> <version>2.6</version> <executions> <execution> <phase>package</phase> <goals> <goal>single</goal> </goals> </execution> </executions> <configuration> <descriptorRefs> <descriptorRef>jar-with-dependencies</descriptorRef> </descriptorRefs> <archive> <manifest> <mainClass>my.company.dice.MainKt</mainClass> </manifest> </archive> </configuration> </plugin> </plugins> </build> <properties> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <maven.compiler.source>1.8</maven.compiler.source> <maven.compiler.target>1.8</maven.compiler.target> <kotlin.version>1.3.20</kotlin.version> <kotlin.compiler.incremental>true</kotlin.compiler.incremental> </properties> </project>
Kami telah melihat skrip untuk membangun dan menjalankan proyek ini - kami tidak akan mulai mengulanginya.PetualanganNah, akhirnya, dalam proyek terpisah kami mengambil plot. Yaitu, semua skenario, medan, musuh, dan objek unik lainnya dari dunia game yang dapat dibayangkan oleh staf departemen skenario perusahaan Anda (yah, atau sejauh ini hanya imajinasi kami yang sakit - kami masih satu-satunya perancang game di wilayah tersebut). Idenya adalah untuk mengelompokkan skrip ke dalam set (petualangan) dan mendistribusikan masing-masing set tersebut sebagai proyek yang terpisah (mirip dengan bagaimana hal itu dilakukan di dunia permainan papan dan video). Yaitu, kumpulkan arsip jar dan letakkan di folder terpisah sehingga mesin game memindai folder ini dan secara otomatis menghubungkan semua petualangan yang ada di sana. Namun, implementasi teknis dari pendekatan ini penuh dengan kesulitan besar.Di mana untuk memulai? Yah, pertama-tama, dari fakta bahwa kami mendistribusikan template dalam bentuk kelas java tertentu (ya, pukul saya dan tegur saya - saya meramalkan ini). Dan jika demikian, maka kelas-kelas ini harus berada di kelas aplikasi pada saat startup. Menegakkan persyaratan ini tidak sulit - Anda secara eksplisit mendaftarkan file jar Anda dalam variabel lingkungan yang sesuai (dimulai dengan Java 6, Anda bahkan dapat menggunakan * - wildcard ). java -classpath "path_to_project/DiceCli/target/adventures/*" -jar path_to_project/DiceCli/target/dice-1.0-jar-with-dependencies.jar
"Orang bodoh, atau apa? Saat menggunakan sakelar -jar, sakelar -kelas diabaikan! "Namun, ini tidak akan berhasil. Classpath untuk arsip jar yang dapat dieksekusi harus secara eksplisit ditulis dalam file internal META-INF/MANIFEST.MF
(bagian ini disebut - Claspath:
). Tidak apa-apa, bahkan ada plugin khusus untuk ini ( maven-compiler-plugin atau, paling buruk, maven-assembly-plugin ). Tetapi wildcard dalam manifes, sayangnya, tidak berfungsi - Anda harus secara eksplisit menentukan nama-nama file jar yang tergantung. Yaitu, untuk mengenal mereka terlebih dahulu, yang dalam kasus kami bermasalah.Lagi pula, saya tidak menginginkan itu. Saya ingin proyek tidak harus dikompilasi ulang. Ke folderadventures/
Anda bisa melempar sejumlah petualangan, dan agar semuanya dapat dilihat oleh mesin permainan selama eksekusi. Sayangnya, fungsi yang tampak jelas melampaui representasi standar dunia Jawa. Karena itu, tidak disambut baik. Pendekatan yang berbeda perlu diambil untuk menyebarkan petualangan independen. Yang mana Saya tidak tahu, menulis di komentar - pasti seseorang memiliki ide cerdas.Sementara itu, tidak ada ide, inilah trik kecil (atau besar, tergantung pada bagaimana Anda melihat) yang memungkinkan Anda untuk secara dinamis menambahkan dependensi ke classpath tanpa mengetahui nama mereka dan tanpa harus mengkompilasi ulang proyek:Di Windows: @ECHO OFF call "path_to_maven\mvn.bat" -f "path_to_project\DiceCore\pom.xml" install call "path_to_maven\mvn.bat" -f "path_to_project\DiceCli\pom.xml" package call "path_to_maven\mvn.bat" -f "path_to_project\TestAdventure\pom.xml" package mkdir path_to_project\DiceCli\target\adventures copy "path_to_project\TestAdventure\target\test-adventure-1.0.jar" path_to_project\DiceCli\target\adventures\ chcp 65001 cd path_to_project\DiceCli\target\ java -Dfile.encoding=UTF-8 -cp "dice-cli-1.0-jar-with-dependencies.jar;adventures\*" my.company.dice.MainKt pause
Dan di Unix:
Dan inilah triknya. Alih-alih menggunakan kunci, -jar
kami menambahkan proyek Cli ke classpath dan secara eksplisit menentukan kelas yang terkandung di dalamnya sebagai titik masuk MainKt
. Plus di sini kami menghubungkan semua arsip dari folder adventures/
.Tidak perlu sekali lagi menunjukkan seberapa banyak keputusan yang bengkok ini - saya sendiri tahu, terima kasih. Lebih baik sarankan ide Anda di komentar. Tolong . (ಥ﹏ಥ)Langkah tujuh belas. Plot
Sedikit lirik.Artikel kami adalah tentang sisi teknis alur kerja, tetapi permainan bukan hanya kode perangkat lunak. Ini adalah dunia yang menarik dengan acara yang menarik dan karakter yang hidup, yang Anda terjun dengan kepala Anda, meninggalkan dunia nyata. Setiap dunia seperti itu tidak biasa dengan caranya sendiri dan menarik dengan caranya sendiri, banyak yang masih Anda ingat, setelah bertahun-tahun. Jika Anda ingin dunia Anda diingat dengan perasaan hangat juga, buatlah itu tidak biasa dan menarik.Saya tahu bahwa kami adalah programmer di sini, bukan penulis naskah, tetapi kami memiliki beberapa ide dasar tentang komponen naratif dari genre game (gamer dengan pengalaman, bukan?). Seperti dalam buku mana pun, cerita harus memiliki mata (di mana kita secara bertahap menggambarkan masalah yang dihadapi para pahlawan), pengembangan, dua atau tiga putaran yang menarik, klimaks (momen plot paling akut, ketika pembaca membeku dalam kegembiraan dan lupa bernapas) dan kesudahan (dalam yang secara bertahap sampai pada kesimpulan logisnya). Hindari meremehkan, tidak memiliki dasar logis, dan membuat lubang - semua garis awal harus sampai pada kesimpulan yang memadai.Baiklah, mari kita baca kisah kita kepada orang lain - pandangan yang tidak bias dari samping sangat sering membantu untuk memahami kekurangan yang terjadi dan untuk memperbaikinya tepat waktu.Plot permainan, , . , : ( ) ( ), . , .
— , . , , .
, , - . , , , , . .
Untungnya, saya bukan Tolkien, saya tidak mengerjakan dunia game dengan terlalu detail, tetapi saya mencoba membuatnya cukup menarik dan, yang paling penting, secara logis dapat dibenarkan. Pada saat yang sama, ia membiarkan dirinya untuk memperkenalkan beberapa ambiguitas, yang masing-masing pemain bebas untuk menafsirkan dengan caranya sendiri. Misalnya, di mana pun ia tidak berfokus pada tingkat perkembangan teknologi dunia yang digambarkan: sistem feodal dan lembaga demokrasi modern, tiran jahat dan kelompok penjahat terorganisir, tujuan tertinggi dan kelangsungan hidup biasa, naik bus dan perkelahian di bar - bahkan karakter menembak karena beberapa alasan: dari busur / busur, atau dari senapan serbu. Di dunia ada kemiripan sihir (kehadirannya menambah gameplay untuk kemampuan taktis) dan elemen mistisisme (hanya untuk menjadi).Saya ingin pindah dari klise plot dan barang-barang konsumsi fantasi - semua elf, gnome, naga, raja kulit hitam dan kejahatan dunia absolut ini (serta: pahlawan pilihan, ramalan kuno, artefak super, artefak super, pertempuran epik ... meskipun yang terakhir dapat dibiarkan). Saya juga benar-benar ingin membuat dunia hidup, sehingga setiap karakter yang bertemu (bahkan yang kecil) memiliki cerita dan motivasi sendiri, bahwa unsur-unsur mekanik permainan sesuai dengan hukum dunia, bahwa perkembangan pahlawan terjadi secara alami, bahwa kehadiran musuh dan rintangan di lokasi secara logis dibenarkan oleh fitur lokasi itu sendiri ... dan seterusnya. Sayangnya, keinginan ini memainkan lelucon yang kejam, sangat memperlambat proses pengembangan, dan tidak selalu mungkin untuk meninggalkan konvensi game. Meski demikian, kepuasan dari produk akhir ternyata menjadi urutan besarnya lebih besar.Apa yang ingin saya katakan dengan semua ini? Sebuah plot menarik yang dipikirkan dengan matang mungkin tidak begitu diperlukan, tetapi permainan Anda tidak akan menderita dari kehadirannya: dalam kasus terbaik, pemain akan menikmatinya, dalam keadaan terburuk mereka hanya akan mengabaikannya. Dan mereka yang sangat antusias bahkan akan memaafkan permainan Anda beberapa kekurangan fungsional, hanya untuk mengetahui bagaimana cerita berakhir.Apa selanjutnya
Pemrograman lebih lanjut berakhir dan desain game dimulai . Sekarang saatnya untuk tidak menulis kode, tetapi untuk memikirkan skenario, lokasi, musuh - Anda mengerti, ini semua ampas. Jika Anda masih bekerja sendirian, saya ucapkan selamat kepada Anda - Anda telah mencapai tahap di mana sebagian besar proyek game dijalankan. Di studio AAA besar, orang-orang khusus bekerja sebagai desainer dan penulis naskah yang menerima uang untuk ini - mereka tidak punya tempat lain untuk pergi. Tetapi kami memiliki banyak pilihan: berjalan-jalan, makan, tidur dengan cara biasa - tetapi apa itu, bahkan untuk memulai proyek baru, menggunakan akumulasi pengalaman dan pengetahuan.Jika Anda masih di sini dan ingin melanjutkan dengan segala cara, bersiaplah untuk kesulitan. Kurangnya waktu, kemalasan, kurangnya inspirasi kreatif - sesuatu akan terus-menerus mengganggu Anda. Tidak mudah untuk mengatasi semua hambatan ini (sekali lagi, banyak artikel telah ditulis tentang topik ini), tetapi itu mungkin. Pertama-tama, saya menyarankan Anda untuk hati-hati merencanakan pengembangan lebih lanjut dari proyek ini. Untungnya, kami bekerja untuk kesenangan kami, penerbit tidak mendorong kami, tidak ada yang menuntut pemenuhan tenggat waktu tertentu - yang berarti ada peluang untuk sampai ke titik tanpa tergesa-gesa. Buatlah “peta jalan” proyek, tentukan tahapan utama dan (jika Anda memiliki keberanian) perkiraan persyaratan untuk penerapannya. Dapatkan sendiri buku catatan (Anda dapat elektronik) dan terus-menerus menuliskan ide-ide yang muncul di dalamnya (bahkan tiba-tiba terbangun di tengah malam).Tandai kemajuan Anda dengan tabel (misalnya, semacam itu ) atau alat bantu lainnya. Mulai dokumentasi: baik eksternal, publik ( wiki, misalnya ) untuk komunitas penggemar masa depan yang besar, dan internal, untuk diri Anda sendiri (saya tidak akan membagikan tautan) - percayalah, tanpa itu setelah istirahat sebulan, Anda tidak akan ingat apa tepatnya dan bagaimana Anda melakukannya. Secara umum, tulis sebanyak mungkin informasi yang menyertai game Anda, ingatlah untuk menulis game itu sendiri. Saya mengusulkan opsi dasar, tetapi saya tidak memberikan saran khusus - masing-masing memutuskan sendiri bagaimana lebih mudah baginya untuk mengatur proses kerjanya."Tapi tetap saja, kamu tidak ingin berbicara tentang keseimbangan gim?"Segera siapkan diri Anda untuk fakta bahwa menciptakan game yang sempurna pertama kali tidak akan berhasil. Sebuah prototipe yang berfungsi baik - pada awalnya akan menunjukkan kelayakan proyek, meyakinkan atau mengecewakan Anda dan memberikan jawaban untuk pertanyaan yang sangat penting: "apakah layak untuk melanjutkan?". Namun, ia tidak akan menjawab banyak pertanyaan lain, yang utamanya, mungkin: "apakah akan menarik untuk memainkan permainan saya dalam jangka panjang?" Ada sejumlah besar teori dan artikel (yah, lagi) tentang hal ini. Gim yang menarik seharusnya cukup sulit, karena gim yang terlalu sederhana tidak membuat tantangan bagi pemain. Di sisi lain, jika kesulitannya adalah penghalang, hanya pemain hardcore yang keras kepala atau orang yang mencoba untuk membuktikan sesuatu kepada seseorang akan tetap dari penonton permainan. Permainan harus cukup beragam, idealnya - menyediakan beberapa opsi untuk mencapai tujuan,sehingga setiap pemain memilih opsi sesuai dengan keinginannya. Satu strategi yang lewat seharusnya tidak mendominasi yang lain, jika tidak mereka hanya akan menggunakannya ... Dan seterusnya.Dengan kata lain, gim harus seimbang. Ini terutama berlaku untuk permainan papan, di mana aturannya diformalkan dengan jelas. Bagaimana cara melakukannya?
Saya tidak tahu. Jika Anda tidak memiliki teman matematika yang dapat membuat model matematika (saya pernah melihatnya, mereka melakukannya) dan Anda sendiri tidak mengerti apa-apa tentang ini (dan kami tidak mengerti), maka satu-satunya jalan keluar adalah mengandalkan intuisi playtesting . Pertama-tama mainkan game itu sendiri. Saat Anda lelah - tawarkan untuk bermain sebagai istri Anda. Setelah perceraian, undang kerabat lain, teman, kenalan, orang-orang acak di jalan untuk bermain. Saat Anda dibiarkan benar-benar sendirian, unggah perakitan di Internet. Orang-orang akan tertarik, ingin bermain, dan Anda akan menjawab mereka: "umpan balik dari Anda!". Mungkin seseorang akan menyukai impian Anda dengan cara yang sama Anda lakukan dan ingin bekerja dengan Anda - dengan cara ini Anda akan menemukan orang-orang yang berpikiran sama atau setidaknya kelompok pendukung (mengapa Anda pikir saya menulis artikel ini?) (Hehe).Sambil bercanda, saya berharap kami semua berhasil. Baca lebih lanjut (siapa sangka!) - tentang desain game dan banyak lagi. Semua masalah yang telah kami teliti telah dibahas dalam satu atau lain cara dalam artikel dan literatur (walaupun, jika Anda masih di sini, jelas tidak perlu untuk mendorong Anda untuk membaca). Bagikan kesan Anda, komunikasikan di forum - secara umum, Anda sudah mengenal saya lebih baik dan lebih baik. Jangan malas dan Anda akan berhasil.Pada catatan optimis ini, izinkan saya untuk pergi. Terima kasih atas perhatiannya. Sampai ketemu lagi!"Eh! Yang melihatmu? Bagaimana sekarang meluncurkan semua ini di ponsel? Apakah saya menunggu dengan sia-sia, atau apa? "Kata penutup Android
Untuk menjelaskan integrasi mesin game kami dengan platform Android, mari kita tinggalkan kelas sendirian Game
dan mempertimbangkan kelas yang serupa, tetapi jauh lebih sederhana MainMenu
. Seperti namanya, ini dimaksudkan untuk mengimplementasikan menu utama aplikasi dan, pada kenyataannya, adalah kelas pertama yang dengannya pengguna mulai berinteraksi.Di antarmuka konsol, terlihat seperti ini Seperti sebuah kelas Game
, ia mendefinisikan loop tak terbatas, di setiap iterasi yang layarnya diambil dan perintah diminta dari pengguna. Hanya tidak ada logika yang rumit di sini dan perintah ini jauh lebih kecil. Kami pada dasarnya menerapkan satu hal - "Keluar".Bagan aktivitas untuk menu utama Mudah kan? Tentang itu dan pidato.
Kode juga merupakan urutan besarnya lebih sederhana. class MainMenu( private val renderer: MenuRenderer, private val interactor: MenuInteractor ) { private var actions = ActionList.EMPTY fun start() { Audio.playMusic(Music.MENU_MAIN) actions = ActionList() actions.add(Action.Type.NEW_ADVENTURE) actions.add(Action.Type.CONTINUE_ADVENTURE, false) actions.add(Action.Type.MANUAL, false) actions.add(Action.Type.EXIT) processCycle() } private fun processCycle() { while (true) { renderer.drawMainMenu(actions) when (interactor.pickAction(actions).type) { Action.Type.NEW_ADVENTURE -> TODO() Action.Type.CONTINUE_ADVENTURE -> TODO() Action.Type.MANUAL -> TODO() Action.Type.EXIT -> { Audio.stopMusic() Audio.playSound(Sound.LEAVE) renderer.clearScreen() Thread.sleep(500) return } else -> throw AssertionError("Should not happen") } } } }
Interaksi dengan pengguna diimplementasikan menggunakan antarmuka MenuRenderer
dan MenuInteractor
bekerja sama dengan apa yang sebelumnya terlihat. interface MenuRenderer: Renderer { fun drawMainMenu(actions: ActionList) } interface Interactor { fun anyInput() fun pickAction(list: ActionList): Action }
Seperti yang sudah Anda pahami, kami dengan sadar memisahkan antarmuka dari implementasi tertentu. Yang kita butuhkan sekarang adalah mengganti proyek Cli dengan proyek baru (sebut saja Droid ), menambahkan ketergantungan pada proyek Core . Ayo lakukan.Jalankan Android Studio (biasanya proyek untuk Android dikembangkan di dalamnya), buat proyek sederhana, hapus semua perada standar yang tidak perlu dan hanya menyisakan dukungan untuk bahasa Kotlin. Kami juga menambahkan ketergantungan pada proyek Core , yang disimpan dalam repositori Maven lokal dari mesin kami. apply plugin: 'com.android.application' apply plugin: 'kotlin-android' apply plugin: 'kotlin-android-extensions' android { compileSdkVersion 28 defaultConfig { applicationId "my.company.dice" minSdkVersion 14 targetSdkVersion 28 versionCode 1 versionName "1.0" } } dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" implementation "my.company:dice-core:1.0" }
Secara default, bagaimanapun, tidak ada yang akan melihat ketergantungan kami - Anda harus secara eksplisit menunjukkan kebutuhan untuk menggunakan repositori lokal (mavenLocal) ketika membangun proyek. buildscript { ext.kotlin_version = '1.3.20' repositories { google() jcenter() mavenLocal() } dependencies { classpath 'com.android.tools.build:gradle:3.3.0' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } allprojects { repositories { google() jcenter() mavenLocal() } }
Anda akan melihat bahwa semua kelas yang dikembangkan sebelumnya dapat diakses untuk digunakan, dan antarmuka untuk implementasi. Kami tertarik, oleh dan besar, kita sudah antarmuka akrab: SoundPlayer
, MusicPlayer
, MenuInteractor
(analog GameInteractor
) MenuRenderer
(analog GameRenderer
) dan StringLoader
yang saya akan menulis yang baru, khusus untuk pelaksanaan android. Tetapi sebelum itu, kami akan mencari tahu bagaimana interaksi pengguna dengan sistem baru kami pada umumnya akan terjadi.Untuk rendering elemen antarmuka, kami tidak akan menggunakan komponen standar (tombol, gambar, bidang input, dll.) Android - sebagai gantinya, kami membatasi diri pada kemampuan kelas Canvas
. Untuk melakukan ini, cukup membuat keturunan kelas tunggalView
- ini akan menjadi "kanvas" kita. Dengan input, ini sedikit lebih rumit, karena kita tidak lagi memiliki keyboard, dan antarmuka perlu dirancang sedemikian rupa sehingga input pengguna pada bagian layar tertentu dianggap sebagai input perintah. Untuk melakukan ini, kita akan menggunakan pewaris yang sama View
- dengan cara ini, dia akan bertindak sebagai perantara antara pengguna dan mesin game (mirip dengan bagaimana konsol sistem bertindak sebagai perantara seperti itu).Mari kita buat aktivitas utama untuk View kita dan tulis di manifes. <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="my.company.dice"> <application android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:theme="@style/AppTheme"> <activity android:name=".ui.MainActivity" android:screenOrientation="sensorLandscape" android:configChanges="orientation|keyboardHidden|screenSize"> <intent-filter> <category android:name="android.intent.category.LAUNCHER"/> <action android:name="android.intent.action.MAIN"/> </intent-filter> </activity> </application> </manifest>
Kami memperbaiki aktivitas dalam orientasi lanskap - seperti halnya pada kebanyakan game lain, kami tidak akan dapat memotret potret. Selain itu, kami akan memperluasnya ke seluruh layar perangkat, menulis tema utama yang sesuai. <resources> <style name="AppTheme" parent="android:Theme.Black.NoTitleBar.Fullscreen"/> </resources>
Dan karena kami masuk ke sumber daya, kami mentransfer string lokal yang kami butuhkan dari proyek Cli , membawanya ke format yang diinginkan: <resources> <string name="action_new_adventure_key">N</string> <string name="action_new_adventure_name">ew adventure</string> <string name="action_continue_adventure_key">C</string> <string name="action_continue_adventure_name">ontinue adventure</string> <string name="action_manual_key">M</string> <string name="action_manual_name">anual</string> <string name="action_exit_key">X</string> <string name="action_exit_name">Exit</string> </resources>
Serta file suara dan musik yang digunakan dalam menu utama (masing-masing jenis), masing-masing menempatkannya di dalam /assets/sound/leave.wav
dan /assets/music/menu_main.mp3
.Ketika kami memilah sumber daya, sudah saatnya untuk turun ke desain (ya, lagi). Berbeda dengan konsol, platform Android memiliki fitur arsitekturnya sendiri, yang memaksa kita untuk menggunakan pendekatan dan metode tertentu.Diagram Kelas dan Antarmuka Tunggu, jangan pingsan, sekarang saya akan menjelaskan semuanya secara rinci.Kita akan mulai, mungkin, dengan yang paling sulit - kelas DiceSurface
- ahli waris View
yang paling dipanggil untuk mengikat bagian independen dari sistem kami (jika Anda mau, Anda bisa mewarisinya dari kelas SurfaceView
- atau bahkan GlSurfaceView
- dan menggambar di utas terpisah, tetapi kami memiliki permainan berbasis giliran, miskin dalam animasi , yang tidak memerlukan output grafis yang rumit, jadi kami tidak akan mempersulitnya). Seperti disebutkan sebelumnya, implementasinya akan menyelesaikan dua masalah sekaligus: output gambar dan pemrosesan klik, masing-masing memiliki kesulitan yang tidak terduga. Mari kita pertimbangkan mereka secara berurutan.Ketika kami melukis di konsol, Renderer kami mengirim perintah keluaran dan membentuk gambar di layar. Dalam kasus Android, situasinya sebaliknya - rendering diprakarsai oleh View itu sendiri, yang pada saat metode dijalankan onDraw()
harus sudah tahu apa, bagaimana dan di mana untuk menggambar. Tetapi bagaimana dengan metode drawMainMenu()
antarmuka MainMenu
? Apakah dia tidak mengontrol output sekarang?Mari kita coba selesaikan masalah ini dengan bantuan antarmuka fungsional. Kelas DiceSurface
akan berisi parameter khusus instructions
- pada kenyataannya, satu blok kode yang harus dieksekusi setiap kali metode dipanggil onDraw()
. Renderer, menggunakan metode publik, akan menunjukkan instruksi spesifik mana yang harus diikuti. Jika Anda tertarik, menggunakan pola yang disebut strategi (strategi). Ini terlihat seperti ini: typealias RenderInstructions = (Canvas, Paint) -> Unit class DiceSurface(context: Context) : View(context) { private var instructions: RenderInstructions = { _, _ -> } private val paint = Paint().apply { color = Color.YELLOW style = Paint.Style.STROKE isAntiAlias = true } fun updateInstructions(instructions: RenderInstructions) { this.instructions = instructions this.postInvalidate() } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) canvas.drawColor(Color.BLACK)
Artinya, semua fungsi grafis masih berada di kelas Renderer, tetapi kali ini kami tidak secara langsung menjalankan perintah, tetapi menyiapkannya untuk dieksekusi oleh Tampilan kami. Perhatikan jenis properti instructions
- Anda dapat membuat antarmuka terpisah dan memanggil satu-satunya metode, tetapi Kotlin dapat secara signifikan mengurangi jumlah kode.Sekarang tentang Interactor. Sebelumnya, entri data terjadi secara serempak: ketika kami meminta data dari konsol (keyboard), aplikasi (siklus) dijeda hingga pengguna menekan tombol. Dengan Android, trik semacam itu tidak akan berfungsi - ia memiliki Loopernya sendiri, pekerjaan yang kami tidak boleh mengganggu, yang artinya input harus asinkron. Artinya, metode antarmuka Interactor masih menjeda mesin dan menunggu perintah, sementara Aktivitas dan semua Pandangannya terus bekerja hingga cepat atau lambat mereka mengirim perintah ini.Pendekatan ini cukup sederhana untuk diimplementasikan menggunakan antarmuka standar BlockingQueue
. Kelas DroidMenuInteractor
akan memanggil metodetake()
, yang akan menunda eksekusi aliran game sampai elemen (instance dari kelas yang sudah dikenal Action
) muncul dalam antrian . DiceSurface
, pada gilirannya, akan menyesuaikan dengan klik pengguna (metode onTouchEvent()
kelas standar View
), menghasilkan objek dan menambahkannya ke antrian oleh metode offer()
. Ini akan terlihat seperti ini: class DiceSurface(context: Context) : View(context) { private val actionQueue: BlockingQueue<Action> = LinkedBlockingQueue<Action>() fun awaitAction(): Action = actionQueue.take() override fun onTouchEvent(event: MotionEvent): Boolean { if (event.action == MotionEvent.ACTION_UP) { actionQueue.offer(Action(Action.Type.NONE), 200, TimeUnit.MILLISECONDS) } return true } } class DroidMenuInteractor(private val surface: DiceSurface) : Interactor { override fun anyInput() { surface.awaitAction() } override fun pickAction(list: ActionList): Action { while (true) { val type = surface.awaitAction().type list .filter(Action::isEnabled) .find { it.type == type } ?.let { return it } } } }
Yaitu, Interactor memanggil metode awaitAction()
dan jika ada sesuatu dalam antrian, ia memproses perintah yang diterima. Perhatikan bagaimana tim ditambahkan ke antrian. Karena aliran UI berjalan terus menerus, pengguna dapat mengklik layar berkali-kali berturut-turut, yang dapat menyebabkan hang, terutama jika mesin gim tidak siap menerima perintah (misalnya, selama animasi). Dalam hal ini, meningkatkan kapasitas antrian dan / atau mengurangi nilai batas waktu akan membantu.Tentu saja, kami semacam mentransfer perintah, tetapi hanya satu-satunya. Kita perlu membedakan antara koordinat penekanan, dan tergantung pada nilainya, panggil perintah ini atau itu. Namun, ini adalah nasib buruk - Interactor tidak tahu di mana tempat di layar tombol aktif ditarik - Renderer bertanggung jawab untuk rendering. Kami akan membangun interaksi mereka sebagai berikut. Kelas DiceSurface
akan menyimpan koleksi khusus - daftar persegi panjang aktif (atau bentuk lain, jika kita pernah mencapai titik ini). Persegi panjang semacam itu berisi koordinat simpul dan simpul Action
. Renderer akan menghasilkan persegi panjang ini dan menambahkannya ke daftar, metode onTouchEvent()
akan menentukan mana dari persegi panjang yang ditekan, dan menambahkan yang sesuai ke antrian Action
. private class ActiveRect(val action: Action, left: Float, top: Float, right: Float, bottom: Float) { val rect = RectF(left, top, right, bottom) fun check(x: Float, y: Float, w: Float, h: Float) = rect.contains(x / w, y / h) }
Metode check()
ini bertanggung jawab untuk memeriksa apakah koordinat yang ditentukan berada di dalam persegi panjang. Harap dicatat bahwa pada tahap kerja Renderer (dan inilah saat ketika persegi panjang dibuat) kami tidak memiliki gagasan sedikit pun tentang ukuran kanvas. Oleh karena itu, kita harus menyimpan koordinat dalam nilai relatif (persentase lebar atau tinggi layar) dengan nilai dari 0 hingga 1 dan menghitung ulang pada saat menekan. Pendekatan ini tidak sepenuhnya akurat, karena tidak memperhitungkan rasio aspek - di masa depan harus diulang. Namun, untuk tugas pendidikan kita pada awalnya itu akan dilakukan.Kami akan menerapkan DiceSurface
bidang tambahan di kelas , menambahkan dua metode ( addRectangle()
dan clearRectangles()
) untuk mengendalikannya dari luar (dari sisi Renderer), dan memperluas onTouchEvent()
dengan memaksa koordinat persegi panjang ke dalam akun. class DiceSurface(context: Context) : View(context) { private val actionQueue: BlockingQueue<Action> = LinkedBlockingQueue<Action>() private val rectangles: MutableSet<ActiveRect> = Collections.newSetFromMap(ConcurrentHashMap<ActiveRect, Boolean>()) private var instructions: RenderInstructions = { _, _ -> } private val paint = Paint().apply { color = Color.YELLOW style = Paint.Style.STROKE isAntiAlias = true } fun updateInstructions(instructions: RenderInstructions) { this.instructions = instructions this.postInvalidate() } fun clearRectangles() { rectangles.clear() } fun addRectangle(action: Action, left: Float, top: Float, right: Float, bottom: Float) { rectangles.add(ActiveRect(action, left, top, right, bottom)) } fun awaitAction(): Action = actionQueue.take() override fun onTouchEvent(event: MotionEvent): Boolean { if (event.action == MotionEvent.ACTION_UP) { with(rectangles.firstOrNull { it.check(event.x, event.y, width.toFloat(), height.toFloat()) }) { if (this != null) { actionQueue.put(action) } else { actionQueue.offer(Action(Action.Type.NONE), 200, TimeUnit.MILLISECONDS) } } } return true } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) canvas.drawColor(Color.BLACK) instructions(canvas, paint) } }
Koleksi kompetitif digunakan untuk menyimpan persegi panjang - itu akan memungkinkan menghindari terjadinya ConcurrentModificationException
jika set diperbarui dan dipindahkan pada saat yang sama oleh utas yang berbeda (yang dalam kasus kami akan terjadi).Kode kelas DroidMenuInteractor
akan tetap tidak berubah, tetapi DroidMenuRenderer
akan berubah. Tambahkan empat tombol ke layar untuk setiap item ActionList
. Tempatkan mereka di bawah judul DICE, didistribusikan secara merata di seluruh lebar layar. Nah, jangan lupa tentang persegi panjang yang aktif. class DroidMenuRenderer ( private val surface: DiceSurface, private val loader: StringLoader ) : MenuRenderer { protected val helper = StringLoadHelper(loader) override fun clearScreen() { surface.clearRectangles() surface.updateInstructions { _, _ -> } } override fun drawMainMenu(actions: ActionList) {
Di sini kami kembali ke antarmuka StringLoader
dan kemampuan kelas tambahan StringLoadHelper
(tidak diperlihatkan dalam diagram). Implementasi yang pertama memiliki nama ResourceStringLoader
dan terlibat dalam memuat string terlokalisasi dari (jelas) sumber daya aplikasi. Namun, ini dilakukan secara dinamis, karena kita tidak tahu pengidentifikasi sumber daya di muka - kita dipaksa untuk membangunnya saat bepergian. class ResourceStringLoader(context: Context) : StringLoader { private val packageName = context.packageName private val resources = context.resources override fun loadString(key: String): String = resources.getString(resources.getIdentifier(key, "string", packageName)) }
Masih berbicara tentang suara dan musik. Ada kelas luar biasa di android MediaPlayer
yang berhubungan dengan hal-hal ini. Tidak ada yang lebih baik untuk memainkan musik: class DroidMusicPlayer(private val context: Context): MusicPlayer { private var currentMusic: Music? = null private val player = MediaPlayer() override fun play(music: Music) { if (currentMusic == music) { return } currentMusic = music player.setAudioStreamType(AudioManager.STREAM_MUSIC) val afd = context.assets.openFd("music/${music.toString().toLowerCase()}.mp3") player.setDataSource(afd.fileDescriptor, afd.startOffset, afd.length) player.setOnCompletionListener { it.seekTo(0) it.start() } player.prepare() player.start() } override fun stop() { currentMusic = null player.release() } }
Dua poin. Pertama, metode prepare()
ini dijalankan secara sinkron, yang dengan ukuran file besar (karena buffering) akan menangguhkan sistem. Disarankan agar Anda menjalankannya di utas terpisah, atau menggunakan metode asinkron prepareAsync()
dan OnPreparedListener
. Kedua, akan lebih baik untuk mengaitkan pemutaran dengan siklus hidup aktivitas (jeda ketika pengguna meminimalkan aplikasi dan melanjutkan ketika memulihkan), tetapi kami tidak melakukannya. Ai-ai-ai ...Ini MediaPlayer
juga cocok untuk suara , tetapi jika sedikit dan sederhana (seperti dalam kasus kami), itu akan berlaku SoundPool
. Keuntungannya adalah ketika file suara sudah dimuat ke dalam memori, pemutarannya langsung dimulai. Kerugiannya jelas - mungkin tidak ada memori yang cukup (tapi cukup bagi kami, kami sederhana). class DroidSoundPlayer(context: Context) : SoundPlayer { private val soundPool: SoundPool = SoundPool(2, AudioManager.STREAM_MUSIC, 100) private val sounds = mutableMapOf<Sound, Int>() private val rate = 1f private val lock = ReentrantReadWriteLock() init { Thread(SoundLoader(context)).start() } override fun play(sound: Sound) { if (lock.readLock().tryLock()) { try { sounds[sound]?.let { s -> soundPool.play(s, 1f, 1f, 1, 0, rate) } } finally { lock.readLock().unlock() } } } private inner class SoundLoader(private val context: Context) : Runnable { override fun run() { val assets = context.assets lock.writeLock().lock() try { Sound.values().forEach { s -> sounds[s] = soundPool.load( assets.openFd("sound/${s.toString().toLowerCase()}.wav"), 1 ) } } finally { lock.writeLock().unlock() } } } }
Saat membuat kelas, semua suara dari enumerasi Sound
dimuat ke dalam repositori dalam aliran terpisah. Kali ini kami tidak menggunakan koleksi yang disinkronkan, tetapi kami menerapkan mutex menggunakan kelas standar ReentrantReadWriteLock
.Sekarang, akhirnya, kita membutakan semua komponen di dalam kita MainActivity
- tidak lupa tentang ini? Harap dicatat bahwa MainMenu
(dan Game
selanjutnya) harus diluncurkan di utas terpisah. class MainActivity : Activity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) Audio.init(DroidSoundPlayer(this), DroidMusicPlayer(this)) val surface = DiceSurface(this) val renderer = DroidMenuRenderer(surface) val interactor = DroidMenuInteractor(surface, ResourceStringLoader(this)) setContentView(surface) Thread { MainMenu(renderer, interactor).start() finish() }.start() } override fun onBackPressed() { } }
Faktanya, itu saja.
Setelah semua siksaan, layar utama aplikasi kita terlihat sangat menakjubkan:Menu utama hingga selebar layar ponsel Yaa, itu akan terlihat luar biasa ketika seorang seniman yang cerdas muncul di barisan kami, dan dengan bantuannya kemelaratan ini akan sepenuhnya digambar ulang.Tautan yang bermanfaat
Saya tahu, banyak yang digulir langsung ke titik ini. Tidak apa-apa - sebagian besar pembaca telah sepenuhnya menutup tab. Unit-unit yang tetap bertahan dalam semua aliran obrolan yang tidak koheren ini - rasa hormat dan hormat, cinta dan syukur yang tak terbatas. Nah dan tautan, tentu saja, di mana tanpa mereka. Pertama-tama, ke kode sumber proyek (perlu diingat bahwa keadaan proyek saat ini telah jauh di depan dari yang dipertimbangkan dalam artikel):Nah, tiba-tiba, seseorang akan memiliki keinginan untuk memulai dan melihat proyek, dan mengumpulkan kemalasan sendiri, berikut ini tautan ke versi yang berfungsi: LINK!Di sini, peluncur yang nyaman digunakan untuk meluncurkan (Anda dapat menulis artikel terpisah tentang pembuatannya). Ini menggunakan JavaFX dan karena itu mungkin tidak memulai pada mesin dengan OpenJDK (menulis dan membantu), tetapi setidaknya menghilangkan kebutuhan untuk mendaftarkan jalur file secara manual. Bantuan instalasi terdapat dalam file readme.txt (ingat itu?). Unduh, tonton, gunakan, dan akhirnya aku diam.Jika Anda tertarik pada suatu proyek, atau alat yang digunakan, atau mekanik, atau beberapa solusi menarik, atau, saya tidak tahu, menyukai permainan, Anda dapat memeriksanya secara lebih rinci dalam artikel terpisah. Jika kamu mau. Dan jika Anda tidak mau, maka cukup kirimkan komentar, penyesalan dan saran. Saya akan senang berbicara.Semua yang terbaik