Arsitektur game / pertanyaan desain - membangun mesin yang efisien sambil menghindari kejadian global (game C ++)


28

Saya punya pertanyaan tentang arsitektur game: Apa cara terbaik untuk memiliki komponen yang berbeda berkomunikasi satu sama lain?

Saya benar-benar minta maaf jika pertanyaan ini telah ditanyakan sejuta kali, tetapi saya tidak dapat menemukan apa pun dengan informasi yang saya cari.

Saya telah mencoba untuk membangun game dari awal (C ++ jika itu penting) dan telah mengamati beberapa perangkat lunak game open source untuk inspirasi (Super Maryo Chronicles, OpenTTD dan lainnya). Saya perhatikan bahwa banyak dari desain game ini menggunakan instance global dan / atau lajang di semua tempat (untuk hal-hal seperti render antrian, manajer entitas, manajer video, dan sebagainya). Saya mencoba menghindari contoh global dan lajang dan membangun sebuah mesin yang longgar mungkin, tapi saya memukul beberapa kendala yang berutang kepada pengalaman saya dalam desain yang efektif. (Bagian dari motivasi untuk proyek ini adalah untuk mengatasi ini :))

Saya telah membangun desain di mana saya memiliki satu GameCoreobjek utama yang memiliki anggota yang analog dengan contoh global yang saya lihat dalam proyek lain (yaitu, ia memiliki manajer input, manajer video, GameStageobjek yang mengontrol semua entitas dan permainan game untuk tahap apa pun yang sedang dimuat, dll). Masalahnya adalah karena semuanya terpusat pada GameCoreobjek, saya tidak memiliki cara mudah untuk komponen yang berbeda untuk berkomunikasi satu sama lain.

Melihat Super Maryo Chronicles, misalnya, setiap kali komponen permainan perlu berkomunikasi dengan komponen lain (yaitu, objek musuh ingin menambahkan dirinya ke antrian render untuk ditarik dalam tahap render), itu hanya berbicara kepada contoh global.

Bagi saya, saya harus membuat objek permainan saya meneruskan informasi yang relevan kembali ke GameCoreobjek tersebut, sehingga GameCoreobjek tersebut dapat meneruskan informasi itu ke komponen lain dari sistem yang membutuhkannya (yaitu: untuk situasi di atas, setiap objek musuh akan meneruskan informasi render mereka kembali ke GameStageobjek, yang akan mengumpulkan semuanya dan meneruskannya kembali GameCore, yang pada gilirannya akan meneruskannya ke pengelola video untuk rendering). Ini terasa seperti desain yang benar-benar mengerikan, dan saya mencoba memikirkan resolusi untuk ini. Pikiranku tentang kemungkinan desain:

  1. Mesin virtual global (desain Super Maryo Chronicles, OpenTTD, dll)
  2. Memiliki GameCoreobjek bertindak sebagai perantara di mana semua objek berkomunikasi (desain saat ini dijelaskan di atas)
  3. Berikan pointer komponen ke semua komponen lain yang perlu mereka bicarakan (yaitu, dalam contoh Maryo di atas, kelas musuh akan memiliki pointer ke objek video yang perlu diajak bicara)
  4. Break game menjadi subsistem - Misalnya, memiliki objek manajer di GameCoreobjek yang menangani komunikasi antara objek di subsistem mereka
  5. (Pilihan lain? ....)

Saya membayangkan opsi 4 di atas untuk menjadi solusi terbaik, tetapi saya mengalami masalah dalam mendesainnya ... mungkin karena saya telah berpikir dalam hal desain yang saya lihat menggunakan global. Rasanya seperti saya mengambil masalah yang sama yang ada dalam desain saya saat ini dan mereplikasi di setiap subsistem, hanya pada skala yang lebih kecil. Sebagai contoh, GameStageobjek yang dijelaskan di atas agak merupakan upaya ini, tetapi GameCoreobjek masih terlibat dalam proses.

Adakah yang bisa menawarkan saran desain di sini?

Terima kasih!


1
Saya mengerti insting Anda bahwa lajang bukan desain yang hebat. Dalam pengalaman saya, mereka telah menjadi cara paling sederhana untuk mengelola komunikasi lintas sistem
Emmett Butler

4
Menambahkan sebagai komentar karena saya tidak tahu apakah ini praktik terbaik. Saya memiliki GameManager pusat yang terdiri dari subsistem seperti InputSystem, GraphicsSystem, dll. Setiap subsistem mengambil GameManager sebagai parameter dalam konstruktor dan menyimpan referensi ke anggota kelas privat. Pada titik itu, saya bisa merujuk ke sistem lain dengan mengaksesnya melalui referensi GameManager.
Inisheer

Saya mengubah tag karena pertanyaan ini tentang kode, bukan desain game.
Klaim

utas ini agak tua, tetapi saya memiliki masalah yang persis sama. Saya menggunakan OGRE dan saya mencoba menggunakan cara terbaik, menurut saya opsi # 4 adalah pendekatan terbaik. Saya telah membangun sesuatu seperti Advanced Ogre Framework, tetapi ini tidak terlalu modular. Saya pikir saya perlu penanganan input subsistem yang hanya mendapatkan klik keyboard dan gerakan mouse. Yang tidak saya pahami adalah, bagaimana saya bisa membuat manajer "komunikasi" di antara subsistem?
Dominik2000

1
Hai @ Dominik2000, ini adalah situs tanya jawab, bukan forum. Jika Anda memiliki pertanyaan, Anda harus memposting pertanyaan yang sebenarnya, dan bukan jawaban yang sudah ada. Lihat faq untuk lebih jelasnya.
Josh

Jawaban:


19

Sesuatu yang kami gunakan dalam game kami untuk mengatur data global kami adalah pola desain ServiceLocator . Keuntungan dari pola ini dibandingkan dengan pola Singleton adalah bahwa implementasi data global Anda dapat berubah selama runtime aplikasi. Juga, objek global Anda dapat diubah selama runtime juga. Keuntungan lain adalah lebih mudah untuk mengatur urutan inisialisasi objek global Anda, yang sangat penting terutama di C ++.

mis. (kode C # yang dapat dengan mudah diterjemahkan ke C ++ atau Java)

Katakanlah Anda memiliki antarmuka rendering backend yang memiliki beberapa operasi umum untuk rendering barang.

public interface IRenderBackend
{
    void Draw();
}

Dan Anda memiliki implementasi backend render default

public class DefaultRenderBackend : IRenderBackend
{
    public void Draw()
    {
        //do default rendering stuff.
    }
}

Dalam beberapa desain tampaknya sah untuk dapat mengakses backend render secara global. Dalam pola Singleton itu berarti bahwa setiap implementasi IRenderBackend harus diimplementasikan sebagai instance global yang unik. Tetapi menggunakan pola ServiceLocator tidak memerlukan ini.

Begini caranya:

public class ServiceLocator<T>
{
    private static T currGlobalInstance;

    public static T Service
    {
        get { return currGlobalInstance; }
        set { currGlobalInstance = value; }
    }
}

Agar dapat mengakses objek global Anda, Anda perlu mengintensifkannya terlebih dahulu.

//somewhere during program initialization
ServiceLocator<IRenderBackend>.Service = new DefaultRenderBackend();

//somewhere else in the code
IRenderBackend currentRenderBackend = ServiceLocator<IRenderBackend>.Service;

Hanya untuk mendemonstrasikan bagaimana implementasi dapat bervariasi selama runtime, katakanlah game Anda memiliki minigame tempat rendering isometrik dan Anda menerapkan IsometrikRenderBackend .

public class IsometricRenderBackend : IRenderBackend
{
    void draw()
    {
        //do rendering using an isometric view
    }
}

Saat Anda bertransisi dari kondisi saat ini ke kondisi minigame, Anda hanya perlu mengubah backend render global yang disediakan oleh pencari layanan.

ServiceLocator<IRenderBackend>.Service = new IsometricRenderBackend();

Keuntungan lain adalah Anda juga dapat menggunakan layanan null. Sebagai contoh, jika kita memiliki ISoundManager layanan dan pengguna ingin menonaktifkan suara, kita hanya bisa menerapkan NullSoundManager yang tidak apa-apa bila metode yang disebut, sehingga dengan menetapkan ServiceLocator 's objek layanan ke NullSoundManager objek kita bisa mencapai hasil ini dengan hampir tidak ada jumlah pekerjaan.

Untuk meringkas, kadang-kadang mungkin tidak mungkin untuk menghilangkan data global tetapi itu tidak berarti bahwa Anda tidak dapat mengaturnya dengan benar dan dengan cara yang berorientasi objek.


Saya sudah melihat ini sebelumnya tetapi tidak benar-benar menerapkannya ke salah satu desain saya. Kali ini, saya berencana untuk. Terima kasih :)
Awesomania

3
@Evisvis Jadi, pada dasarnya, Anda menggambarkan referensi global ke objek polimorfik. Pada gilirannya, ini hanya tipuan ganda (pointer -> antarmuka -> implementasi). Dalam C ++ itu dapat diimplementasikan dengan mudah sebagai std::unique_ptr<ISomeService>.
Shadows In Rain

1
Anda dapat mengubah strategi inisialisasi menjadi "menginisialisasi akses pertama" dan menghindari keharusan mengalokasikan urutan kode eksternal dan mendorong layanan ke pencari lokasi. Anda dapat menambahkan daftar "tergantung pada" ke layanan sehingga ketika seseorang diinisialisasi itu akan secara otomatis mengatur layanan lain yang dibutuhkan dan tidak berdoa agar seseorang ingat untuk melakukannya di main.cpp. Jawaban yang bagus dengan fleksibilitas untuk penyesuaian di masa depan.
Patrick Hughes

4

Ada banyak cara untuk merancang mesin game dan itu semua bermuara pada preferensi.

Untuk mendapatkan dasar-dasar keluar dari jalan, beberapa pengembang lebih suka untuk mendesainnya seperti piramida di mana ada beberapa kelas inti yang sering disebut sebagai kelas kernel, inti atau kerangka kerja yang menciptakan, memiliki, dan menginisialisasi serangkaian subsistem seperti seperti audio, grafik, jaringan, fisika, AI, dan tugas, entitas, dan manajemen sumber daya. Secara umum, subsistem ini dihadapkan kepada Anda oleh kelas kerangka kerja ini dan biasanya Anda akan meneruskan kelas kerangka kerja ini ke kelas Anda sendiri sebagai argumen konstruktor yang sesuai.

Saya yakin Anda berada di jalur yang benar dengan memikirkan pilihan # 4.

Perlu diingat ketika berbicara tentang komunikasi itu sendiri, itu tidak selalu harus menyiratkan panggilan fungsi langsung itu sendiri. Ada banyak cara tidak langsung komunikasi dapat terjadi, apakah itu melalui beberapa metode tidak langsung menggunakan Signal and Slotsatau menggunakan Messages.

Terkadang dalam gim, penting untuk memungkinkan tindakan terjadi secara serempak agar loop game kami bergerak secepat mungkin sehingga frame rate cairan ke mata telanjang. Pemain tidak suka adegan lambat dan berombak dan jadi kami harus menemukan cara untuk menjaga hal-hal mengalir untuk mereka tetapi tetap mengalir logika tetapi di cek dan dipesan juga. Sementara operasi asinkron mengambil tempatnya, mereka juga bukan jawaban untuk setiap operasi.

Ketahuilah bahwa Anda akan memiliki gabungan komunikasi sinkron & asinkron. Pilih yang sesuai, tetapi ketahuilah bahwa Anda harus mendukung kedua gaya di antara subsistem Anda. Merancang dukungan untuk keduanya akan membantu Anda di masa depan.


1

Anda hanya perlu memastikan bahwa tidak ada dependensi terbalik atau siklus. Misalnya, jika Anda memiliki kelas Core, dan ini Corememiliki Level, dan Levelmemiliki daftar Entity, maka pohon dependensi akan terlihat seperti:

Core --> Level --> Entity

Jadi, mengingat pohon dependensi awal ini, Anda seharusnya tidak pernah Entitybergantung pada Levelatau Core, dan Levelseharusnya tidak pernah bergantung pada Core. Jika salah satu Levelatau Entityperlu memiliki akses ke data yang lebih tinggi di pohon dependensi, itu harus dilewatkan sebagai parameter dengan referensi.

Pertimbangkan kode berikut (C ++):

class Core;
class Entity;
class Level;

class Level
{
    public:
        Level(Core& coreIn) : core(coreIn) {}

        Core& core;
}

class Entity
{
    public:
        Entity(Level& levelIn) : level(levelIn) {}

        Level& level;
}

Dengan menggunakan teknik ini, Anda dapat melihat bahwa masing-masing Entitymemiliki akses ke Level, dan Levelmemiliki akses ke Core. Perhatikan bahwa masing-masing Entitymenyimpan referensi ke Levelmemori yang sama , membuang-buang. Setelah memperhatikan ini, Anda harus mempertanyakan apakah masing Entity- masing benar - benar membutuhkan akses ke Internet Level.

Dalam pengalaman saya, ada baik A) Solusi yang sangat jelas untuk menghindari ketergantungan terbalik, atau B) Tidak ada cara yang mungkin untuk menghindari contoh global dan lajang.


Apakah saya melewatkan sesuatu? Anda menyebutkan 'Anda seharusnya tidak pernah memiliki Entitas tergantung pada Level' tetapi kemudian Anda menggambarkan ctornya sebagai 'Entity (Level & levelIn)'. Saya mengerti bahwa ketergantungan dilewatkan oleh ref tetapi masih ketergantungan.
Adam Naylor

@AdamNaylor Intinya adalah bahwa kadang-kadang Anda benar-benar membutuhkan dependensi terbalik, dan Anda dapat menghindari global dengan melewati referensi. Secara umum, bagaimanapun, yang terbaik adalah menghindari ketergantungan ini sepenuhnya, dan tidak selalu jelas bagaimana melakukan ini.
Apel

0

Jadi, pada dasarnya, Anda ingin menghindari negara yang bisa berubah global ? Anda bisa menjadikannya lokal, tidak berubah, atau tidak sama sekali. Latter paling efisien dan fleksibel, imo. Ini dikenal sebagai persembunyian iplementation.

class ISomeComponent // abstract base class
{
    //...
};

extern ISomeComponent & g_SomeComponent; // will be defined somewhere else;

0

Pertanyaannya sebenarnya adalah bagaimana mengurangi kopling tanpa mengorbankan kinerja. Semua objek global (layanan) biasanya membentuk semacam konteks yang bisa berubah selama waktu permainan. Dalam pengertian ini, pola pencari lokasi menyebarkan bagian-bagian yang berbeda dari konteks ke bagian aplikasi yang berbeda, yang mungkin atau mungkin tidak seperti yang Anda inginkan. Pendekatan dunia nyata lainnya adalah dengan mendeklarasikan struktur seperti ini:

struct sEnvironment
{
    owning<iAudio*> m_Audio;
    owning<iRenderer*> m_Renderer;
    owning<iGameLevel*> m_GameLevel;
    ...
}

Dan menyebarkannya sebagai penunjuk mentah yang tidak memiliki sEnvironment*. Di sini pointer menunjuk ke antarmuka sehingga kopling berkurang dengan cara yang serupa dibandingkan dengan pencari lokasi. Namun, semua layanan berada di satu tempat (yang mungkin atau mungkin tidak baik). Ini hanyalah pendekatan lain.

Dengan menggunakan situs kami, Anda mengakui telah membaca dan memahami Kebijakan Cookie dan Kebijakan Privasi kami.
Licensed under cc by-sa 3.0 with attribution required.