Menggunakan sistem entitas berbasis komponen secara praktis

59

Kemarin, saya sudah membaca presentasi dari GDC Canada tentang sistem entitas Attribute / Behavior dan saya pikir ini cukup bagus. Namun, saya tidak yakin bagaimana menggunakannya secara praktis, tidak hanya secara teori. Pertama-tama, saya akan segera menjelaskan kepada Anda bagaimana sistem ini bekerja.


Setiap entitas game (objek game) terdiri dari atribut (= data, yang dapat diakses oleh perilaku, tetapi juga oleh 'kode eksternal') dan perilaku (= logika, yang berisi OnUpdate()dan OnMessage()). Jadi, misalnya, dalam klon Breakout, setiap bata akan terdiri dari (misalnya!): PositionAttribute , ColorAttribute , HealthAttribute , RenderableBehaviour , HitBehaviour . Yang terakhir bisa terlihat seperti ini (itu hanya contoh tidak berfungsi yang ditulis dalam C #):

void OnMessage(Message m)
{
    if (m is CollisionMessage) // CollisionMessage is inherited from Message
    {
        Entity otherEntity = m.CollidedWith; // Entity CollisionMessage.CollidedWith
        if (otherEntity.Type = EntityType.Ball) // Collided with ball
        {
            int brickHealth = GetAttribute<int>(Attribute.Health); // owner's attribute
            brickHealth -= otherEntity.GetAttribute<int>(Attribute.DamageImpact);
            SetAttribute<int>(Attribute.Health, brickHealth); // owner's attribute

            // If health is <= 0, "destroy" the brick
            if (brickHealth <= 0)
                SetAttribute<bool>(Attribute.Alive, false);
        }
    }
    else if (m is AttributeChangedMessage) // Some attribute has been changed 'externally'
    {
        if (m.Attribute == Attribute.Health)
        {
            // If health is <= 0, "destroy" the brick
            if (brickHealth <= 0)
                SetAttribute<bool>(Attribute.Alive, false);
        }
    }
}

Jika Anda tertarik dengan sistem ini, Anda dapat membaca lebih lanjut di sini (.ppt).


Pertanyaan saya terkait dengan sistem ini, tetapi umumnya setiap sistem entitas berbasis komponen. Saya belum pernah melihat bagaimana semua ini benar-benar bekerja dalam permainan komputer nyata, karena saya tidak dapat menemukan contoh yang baik dan jika saya menemukannya, itu tidak didokumentasikan, tidak ada komentar dan jadi saya tidak memahaminya.

Jadi, apa yang ingin saya tanyakan? Cara mendesain perilaku (komponen). Saya sudah baca di sini, di GameDev SE, bahwa kesalahan paling umum adalah membuat banyak komponen dan hanya, "membuat semuanya menjadi komponen". Saya telah membaca bahwa itu disarankan untuk tidak melakukan rendering dalam komponen, tetapi melakukannya di luar itu (jadi alih-alih RenderableBehaviour , itu mungkin harus RenderableAttribute , dan jika suatu entitas memiliki RenderableAttribute diatur ke true, maka Renderer(kelas tidak terkait dengan komponen, tetapi ke mesin itu sendiri) harus menariknya di layar?).

Tapi, bagaimana dengan perilaku / komponennya? Katakanlah saya punya level, dan di level, ada Entity button, Entity doorsdan Entity player. Ketika pemain bertabrakan dengan tombol (itu adalah tombol lantai, yang diubah oleh tekanan), itu ditekan. Ketika tombol ditekan, itu membuka pintu. Nah, sekarang bagaimana cara melakukannya?

Saya datang dengan sesuatu seperti ini: pemain mendapat CollisionBehaviour , yang memeriksa apakah pemain bertabrakan dengan sesuatu. Jika dia bertabrakan dengan sebuah tombol, itu akan mengirimkan CollisionMessageke buttonentitas. Pesan akan berisi semua informasi yang diperlukan: siapa yang bertabrakan dengan tombol. Tombol telah mendapat ToggleableBehaviour , yang akan menerima CollisionMessage. Ini akan memeriksa siapa yang bertabrakan dan jika bobot entitas itu cukup besar untuk beralih tombol, tombol akan beralih. Sekarang, ini mengatur ToggledAttribute dari tombol menjadi true. Baiklah, tapi bagaimana sekarang?

Haruskah tombol mengirim pesan lain ke semua objek lain untuk memberi tahu mereka bahwa itu telah di-toggle? Saya pikir jika saya melakukan semuanya seperti ini, saya akan memiliki ribuan pesan dan itu akan menjadi sangat berantakan. Jadi mungkin ini lebih baik: pintu-pintu terus-menerus memeriksa apakah tombol yang tertaut padanya ditekan atau tidak, dan mengubah OpenedAttribute - nya sesuai. Tetapi kemudian itu berarti bahwa metode pintu OnUpdate()akan terus melakukan sesuatu (apakah ini benar-benar masalah?).

Dan masalah kedua: bagaimana jika saya memiliki lebih banyak jenis tombol. Satu ditekan oleh tekanan, yang kedua beralih dengan menembaki itu, yang ketiga beralih jika air dituangkan di atasnya, dll. Ini berarti bahwa saya harus memiliki perilaku yang berbeda, seperti ini:

Behaviour -> ToggleableBehaviour -> ToggleOnPressureBehaviour
                                 -> ToggleOnShotBehaviour
                                 -> ToggleOnWaterBehaviour

Apakah ini cara kerja gim yang sebenarnya atau apakah saya bodoh? Mungkin saya bisa memiliki hanya satu ToggleableBehaviour dan itu akan berperilaku sesuai dengan ButtonTypeAttribute . Jadi jika itu adalah ButtonType.Pressure, ia melakukan ini, jika itu ButtonType.Shot, ia melakukan sesuatu yang lain ...

Jadi apa yang saya inginkan? Saya ingin bertanya kepada Anda apakah saya melakukannya dengan benar, atau saya hanya bodoh dan saya tidak mengerti maksud komponennya. Saya tidak menemukan contoh yang bagus tentang bagaimana komponen bekerja dalam game, saya menemukan hanya beberapa tutorial yang menjelaskan cara membuat sistem komponen, tetapi tidak bagaimana menggunakannya.

TomsonTom
sumber

Jawaban:

46

Komponen memang bagus, tetapi perlu beberapa saat untuk menemukan solusi yang terasa nyaman bagi Anda. Jangan khawatir, Anda akan sampai di sana. :)

Pengorganisasian komponen

Anda cukup banyak berada di jalur yang benar, saya katakan. Saya akan mencoba menggambarkan solusinya secara terbalik, mulai dari pintu dan diakhiri dengan sakelar. Implementasi saya banyak memanfaatkan acara; di bawah ini saya jelaskan bagaimana Anda dapat menggunakan acara dengan lebih efisien sehingga tidak menjadi masalah.

Jika Anda memiliki mekanisme untuk menghubungkan entitas di antara mereka, saya akan meminta sakelar secara langsung memberitahukan pintu yang telah ditekan, maka pintu dapat memutuskan apa yang harus dilakukan.

Jika Anda tidak dapat menghubungkan entitas, solusi Anda cukup dekat dengan apa yang akan saya lakukan. Saya ingin pintu mendengarkan acara umum ( SwitchActivatedEvent, mungkin). Ketika sakelar diaktifkan, mereka memposting acara ini.

Jika Anda memiliki lebih dari satu jenis saklar, saya akan memiliki PressureToggle, WaterToggledan ShotToggleperilaku juga, tapi saya tidak yakin basisnya ToggleableBehaviourbaik, jadi saya akan menghapusnya (kecuali, tentu saja, Anda memiliki yang baik alasan untuk menyimpannya).

Behaviour -> ToggleOnPressureBehaviour
          -> ToggleOnShotBehaviour
          -> ToggleOnWaterBehaviour

Penanganan acara yang efisien

Sedangkan untuk khawatir bahwa ada terlalu banyak kejadian di sekitar, ada satu hal yang bisa Anda lakukan. Alih-alih meminta setiap komponen diberitahu tentang setiap peristiwa tunggal yang terjadi, maka periksalah komponen tersebut apakah itu jenis acara yang tepat, inilah mekanisme yang berbeda ...

Anda dapat EventDispatchermenggunakan subscribemetode yang mirip dengan ini (pseudocode):

EventDispatcher.subscribe(event_type, function)

Kemudian, ketika Anda memposting suatu peristiwa, operator memeriksa jenisnya dan hanya memberi tahu fungsi-fungsi yang berlangganan jenis acara tertentu. Anda dapat menerapkan ini sebagai peta yang mengaitkan jenis acara dengan daftar fungsi.

Dengan cara ini, sistem ini secara signifikan lebih efisien: ada jauh lebih sedikit panggilan fungsi per peristiwa, dan komponen dapat dipastikan bahwa mereka menerima jenis acara yang tepat dan tidak perlu memeriksa ulang.

Saya telah memposting implementasi sederhana ini beberapa waktu lalu di StackOverflow. Ini ditulis dengan Python, tapi mungkin masih bisa membantu Anda:
https://stackoverflow.com/a/7294148/627005

Implementasinya cukup umum: ia bekerja dengan semua jenis fungsi, bukan hanya fungsi dari komponen. Jika Anda tidak membutuhkannya, alih-alih function, Anda dapat memiliki behaviorparameter dalam subscribemetode Anda - contoh perilaku yang perlu diberitahukan.

Atribut dan perilaku

Saya datang sendiri untuk menggunakan atribut dan perilaku , alih-alih komponen lama yang sederhana. Namun, dari deskripsi Anda tentang bagaimana Anda akan menggunakan sistem dalam game Breakout, saya pikir Anda berlebihan.

Saya menggunakan atribut hanya ketika dua perilaku memerlukan akses ke data yang sama. Atribut membantu menjaga perilaku terpisah dan ketergantungan antara komponen (baik atribut atau perilaku) tidak menjadi terjerat, karena mereka mengikuti aturan yang sangat sederhana dan jelas:

  • Atribut tidak menggunakan komponen lain (tidak atribut lain, atau perilaku), mereka mandiri.

  • Perilaku tidak menggunakan atau tahu tentang perilaku lain. Mereka hanya tahu tentang beberapa atribut (yang sangat mereka butuhkan).

Ketika beberapa data hanya dibutuhkan oleh satu dan hanya satu perilaku, saya tidak melihat alasan untuk memasukkannya ke dalam atribut, saya membiarkan perilaku tersebut menahannya.


Komentar @ heishe

Bukankah masalah itu terjadi dengan komponen normal juga?

Bagaimanapun, saya tidak perlu memeriksa jenis acara karena setiap fungsi pasti akan menerima jenis acara yang tepat, selalu .

Juga, dependensi perilaku (mis. Atribut yang mereka butuhkan) diselesaikan pada konstruksi, jadi Anda tidak perlu mencari atribut setiap pada setiap pembaruan.

Dan terakhir, saya menggunakan Python untuk kode logika permainan saya (mesinnya ada di C ++), jadi tidak perlu casting. Python melakukan hal-itik mengetik dan semuanya berfungsi dengan baik. Tetapi bahkan jika saya tidak menggunakan bahasa dengan mengetik bebek, saya akan melakukan ini (contoh sederhana):

class SomeBehavior
{
  public:
    SomeBehavior(std::map<std::string, Attribute*> attribs, EventDispatcher* events)
        // For the purposes of this example, I'll assume that the attributes I
        // receive are the right ones. 
        : health_(static_cast<HealthAttribute*>(attribs["health"])),
          armor_(static_cast<ArmorAttribute*>(attribs["armor"]))
    {
        // Boost's polymorphic_downcast would probably be more secure than
        // a static_cast here, but nonetheless...
        // Also, I'd probably use some smart pointers instead of plain
        // old C pointers for the attributes.

        // This is how I'd subscribe a function to a certain type of event.
        // The dispatcher returns a `Subscription` object; the subscription 
        // is alive for as long this object is alive.
        subscription_ = events->subscribe(event::type<DamageEvent>(),
            std::bind(&SomeBehavior::onDamageEvent, this, _1));
    }

    void onDamageEvent(std::shared_ptr<Event> e)
    {
        DamageEvent* damage = boost::polymorphic_downcast<DamageEvent*>(e.get());
        // Simplistic and incorrect formula: health = health - damage + armor
        health_->value(health_->value() - damage->amount() + armor_->protection());
    }

    void update(boost::chrono::duration timePassed)
    {
        // Behaviors also have an `update` function, just like
        // traditional components.
    }

  private:
    HealthAttribute* health_;
    ArmorAttribute* armor_;
    EventDispatcher::Subscription subscription_;
};

Tidak seperti perilaku, atribut tidak memiliki updatefungsi - mereka tidak perlu, tujuannya adalah untuk menyimpan data, bukan untuk melakukan logika permainan yang kompleks.

Atribut Anda masih dapat melakukan beberapa logika sederhana. Dalam contoh ini, a HealthAttributememastikan bahwa 0 <= value <= max_healthitu selalu benar. Itu juga dapat mengirim HealthCriticalEventke komponen lain dari entitas yang sama ketika turun di bawah, katakanlah, 25 persen, tetapi tidak dapat melakukan logika yang lebih kompleks dari itu.


Contoh kelas atribut:

class HealthAttribute : public EntityAttribute
{
  public:
    HealthAttribute(Entity* entity, double max, double critical)
        : max_(max), critical_(critical), current_(max)
    { }

    double value() const {
        return current_;
    }    

    void value(double val)
    {
        // Ensure that 0 <= current <= max 
        if (0 <= val && val <= max_)
            current_ = val;

        // Notify other components belonging to this entity that
        // health is too low.
        if (current_ <= critical_) {
            auto ev = std::shared_ptr<Event>(new HealthCriticalEvent())
            entity_->events().post(ev)
        }
    }

  private:
    double current_, max_, critical_;
};
Paul Manta
sumber
Terima kasih! Ini persis seperti yang saya inginkan. Saya juga menyukai ide Anda tentang EventDispatcher lebih baik daripada pesan sederhana yang dikirimkan ke semua entitas. Sekarang, untuk hal terakhir yang Anda katakan: pada dasarnya Anda mengatakan bahwa Health and DamageImpact tidak harus menjadi atribut dalam contoh ini. Jadi, alih-alih atribut, itu hanya variabel pribadi dari perilaku? Itu berarti, bahwa "DamageImpact" akan dilewati melalui acara tersebut? Misalnya EventArgs.DamageImpact? Kedengarannya bagus ... Tetapi jika saya ingin batu bata berubah warna sesuai dengan kesehatannya, maka Kesehatan harus menjadi atribut, bukan? Terima kasih!
TomsonTom
2
@ TomsonTom Ya, itu dia. Memiliki acara yang menyimpan data apa pun yang perlu diketahui pendengar adalah solusi yang sangat baik.
Paul Manta
3
Ini jawaban yang bagus! (seperti pdf Anda) - Ketika Anda memiliki kesempatan, dapatkah Anda menguraikan sedikit tentang cara Anda menangani rendering dengan sistem ini? Model atribut / perilaku ini benar-benar baru bagi saya, tetapi sangat menarik.
Michael
1
@ TomsonTom Tentang rendering, lihat jawaban yang saya berikan kepada Michael. Adapun tabrakan, saya pribadi mengambil jalan pintas. Saya menggunakan perpustakaan bernama Box2D yang cukup mudah digunakan dan menangani tabrakan jauh lebih baik daripada yang saya bisa. Tapi saya tidak menggunakan perpustakaan secara langsung dalam kode logika permainan saya. Setiap Entitymemiliki EntityBody, yang abstrak semua bit jelek. Perilaku kemudian dapat membaca posisi dari EntityBody, menerapkan kekuatan untuk itu, menggunakan sendi dan motor yang dimiliki tubuh, dll. Memiliki simulasi fisika kesetiaan yang tinggi seperti Box2D tentu saja membawa tantangan baru, tetapi mereka cukup menyenangkan, imo.
Paul Manta
1
@thelinuxlich Jadi Anda adalah pengembang Artemis! : D Saya pernah melihat skema Component/ Systemdireferensikan beberapa kali di papan tulis. Implementasi kami memang memiliki beberapa kesamaan.
Paul Manta