42 votes

Les objets du jeu se parlent entre eux

Quelle est une bonne façon de traiter les objets et de les faire dialoguer entre eux ?

Jusqu'à présent, tous mes jeux hobby/étudiant ont été de petite taille, donc ce problème a généralement été résolu d'une manière plutôt laide, ce qui a conduit à une intégration étroite et à des dépendances circulaires. Ce qui était bien pour la taille des projets que je faisais.

Cependant, mes projets sont devenus de plus en plus volumineux et complexes et je veux maintenant commencer à réutiliser le code et à simplifier ma tête.

Le principal problème que je rencontre est généralement du type suivant Player a besoin de connaître le Map et il en va de même pour le Enemy En général, cela se traduit par la mise en place d'un grand nombre de pointeurs et de dépendances, ce qui devient rapidement une catastrophe.

J'ai pensé à un système de type message, mais je ne vois pas vraiment comment cela réduit les dépendances, car je continuerais à envoyer les pointeurs partout.

PS : Je suppose que ce sujet a déjà été abordé, mais je ne sais pas comment il s'appelle, juste le besoin que j'ai.

47voto

James Points 918

EDIT : Je décris ci-dessous un système de base de messagerie événementielle que j'ai utilisé maintes et maintes fois. Et il m'est apparu que ces deux projets scolaires sont open source et sur le web. Vous pouvez trouver la deuxième version de ce système de messagerie (et bien d'autres choses) à l'adresse suivante http://sourceforge.net/projects/bpfat/ .. Profitez-en, et lisez ci-dessous pour une description plus approfondie du système !

J'ai écrit un système de messagerie générique et je l'ai introduit dans une poignée de jeux qui sont sortis sur la PSP ainsi que dans certains logiciels d'application d'entreprise. Le but du système de messagerie est de ne faire circuler que les données nécessaires au traitement d'un message ou d'un événement, selon la terminologie que vous souhaitez utiliser, afin que les objets n'aient pas à se connaître les uns les autres.

Voici un bref aperçu de la liste des objets utilisés à cette fin :

struct TEventMessage
{
    int _iMessageID;
}

class IEventMessagingSystem
{
    Post(int iMessageId);
    Post(int iMessageId, float fData);
    Post(int iMessageId, int iData);
    // ...
    Post(TMessageEvent * pMessage);
    Post(int iMessageId, void * pData);
}

typedef float(*IEventMessagingSystem::Callback)(TEventMessage * pMessage);

class CEventMessagingSystem
{
    Init       ();
    DNit       ();
    Exec       (float fElapsedTime);

    Post       (TEventMessage * oMessage);

    Register   (int iMessageId, IEventMessagingSystem* pObject, FObjectCallback* fpMethod);
    Unregister (int iMessageId, IEventMessagingSystem* pObject, FObjectCallback * fpMethod);
}

#define MSG_Startup            (1)
#define MSG_Shutdown           (2)
#define MSG_PlaySound          (3)
#define MSG_HandlePlayerInput  (4)
#define MSG_NetworkMessage     (5)
#define MSG_PlayerDied         (6)
#define MSG_BeginCombat        (7)
#define MSG_EndCombat          (8)

Et maintenant, un peu d'explication. Le premier objet, TEventMessage, est l'objet de base pour représenter les données envoyées par le système de messagerie. Par défaut, il aura toujours l'Id du message envoyé, donc si vous voulez vous assurer que vous avez reçu un message que vous attendiez, vous pouvez le faire (généralement, je ne le fais qu'en débogage).

Ensuite, il y a la classe Interface qui donne un objet générique pour le système de messagerie à utiliser pour le casting tout en faisant des callbacks. En outre, elle fournit également une interface "facile à utiliser" pour l'envoi de différents types de données au système de messagerie.

Après cela, nous avons notre typedef Callback. Pour faire simple, il attend un objet du type de la classe d'interface et transmet un pointeur TEventMessage... En option, vous pouvez rendre le paramètre constant, mais j'ai déjà utilisé le traitement en amont pour des choses comme le débogage de la pile et autres du système de messagerie.

Le dernier et le plus important est l'objet CEventMessagingSystem. Cet objet contient un tableau de piles d'objets de rappel (ou des listes liées ou des files d'attente ou tout autre moyen de stocker les données). Les objets de rappel, non représentés ci-dessus, doivent maintenir (et sont définis de manière unique par) un pointeur vers l'objet ainsi que la méthode à appeler sur cet objet. Lorsque vous enregistrez(), vous ajoutez une entrée sur la pile d'objets sous la position du tableau de l'id du message. Lorsque vous désenregistrez(), vous supprimez cette entrée.

C'est à peu près tout. Maintenant, il est stipulé que tout doit connaître l'IEventMessagingSystem et l'objet TEventMessage... mais cet objet ne doit pas changer souvent et ne doit transmettre que les parties de l'information qui sont vitales pour la logique dictée par l'événement appelé. De cette façon, un joueur n'a pas besoin de connaître la carte ou l'ennemi directement pour lui envoyer des événements. Un objet géré peut également appeler une API vers un système plus large, sans avoir besoin de savoir quoi que ce soit à son sujet.

Par exemple : Quand un ennemi meurt, vous voulez qu'il joue un effet sonore. En supposant que vous ayez un gestionnaire de sons qui hérite de l'interface IEventMessagingSystem, vous pourriez configurer un callback pour le système de messagerie qui accepterait un TEventMessagePlaySoundEffect ou quelque chose de ce genre. Le Sound Manager enregistrerait alors ce callback lorsque les effets sonores sont activés (ou désenregistrerait le callback lorsque vous souhaitez couper tous les effets sonores pour faciliter les capacités d'activation et de désactivation). Ensuite, l'objet ennemi hérite également de IEventMessagingSystem, crée un objet TEventMessagePlaySoundEffect (il a besoin de MSG_PlaySound pour son ID de message, puis de l'ID de l'effet sonore à jouer, qu'il s'agisse d'un ID int ou du nom de l'effet sonore) et appelle simplement Post(&oEventMessagePlaySoundEffect).

Il ne s'agit que d'une conception très simple, sans mise en œuvre. Si vous avez une exécution immédiate, vous n'avez pas besoin de mettre en mémoire tampon les objets TEventMessage (ce que j'ai utilisé principalement dans les jeux de console). Si vous êtes dans un environnement multithread, il s'agit d'un moyen très bien défini pour que les objets et les systèmes fonctionnant dans des threads séparés puissent communiquer entre eux, mais vous voudrez conserver les objets TEventMessage afin que les données soient disponibles lors du traitement.

Une autre modification est que pour les objets qui n'ont jamais besoin de poster des données, vous pouvez créer un ensemble statique de méthodes dans le IEventMessagingSystem afin qu'ils n'aient pas à en hériter (cela est utilisé pour faciliter l'accès et les capacités de rappel, et n'est pas -directement- nécessaire pour les appels Post()).

Pour toutes les personnes qui mentionnent MVC, c'est un très bon modèle, mais vous pouvez le mettre en œuvre de tellement de manières différentes et à des niveaux différents. Le projet actuel sur lequel je travaille professionnellement est une configuration MVC environ 3 fois plus, il y a le MVC global de l'application entière et ensuite, au niveau de la conception, chaque M V et C est aussi un modèle MVC autonome. Donc, ce que j'ai essayé de faire ici est d'expliquer comment faire un C qui est assez générique pour gérer à peu près n'importe quel type de M sans avoir besoin d'entrer dans un View...

Par exemple, un objet qui meurt peut vouloir jouer un effet sonore Vous feriez une structure pour le système de son comme TEventMessageSoundEffect qui hérite du TEventMessage et ajoute un ID d'effet sonore (que ce soit un Int préchargé, ou le nom du fichier sfx, peu importe comment ils sont suivis dans votre système). Ensuite, il suffit à l'objet d'assembler un objet TEventMessageSoundEffect avec le bruit de mort approprié et d'appeler Post(&oEventMessageSoundEffect) ; objet En supposant que le son n'est pas coupé (ce que vous voudriez pour Unregister les Sound Managers.

EDIT : Pour clarifier un peu ceci en ce qui concerne le commentaire ci-dessous : Tout objet pour envoyer ou recevoir un message a juste besoin de connaître l'interface IEventMessagingSystem, et c'est le seul objet que l'EventMessagingSystem doit connaître de tous les autres objets. C'est ce qui permet le détachement. Tout objet qui veut recevoir un message doit simplement l'enregistrer (MSG, Object, Callback). Ensuite, lorsqu'un objet appelle Post(MSG,Data), il l'envoie à l'EventMessagingSystem via l'interface qu'il connaît, l'EMS notifiera alors chaque objet enregistré de l'événement. Vous pourriez faire un MSG_PlayerDied que d'autres systèmes gèrent, ou le joueur peut appeler MSG_PlaySound, MSG_Respawn, etc. pour permettre aux objets qui écoutent ces messages d'agir en conséquence. Considérez la fonction Post(MSG,Data) comme une API abstraite pour les différents systèmes d'un moteur de jeu.

Oh ! Une autre chose qui m'a été signalée. Le système que je décris ci-dessus correspond au schéma de l'observateur dans l'autre réponse donnée. Donc si vous voulez une description plus générale pour que la mienne ait un peu plus de sens, voici un court article qui en donne une bonne description.

J'espère que cela vous aidera et profitez-en !

1 votes

+1 pour l'explication approfondie, mais j'ai aussi une remarque : vous avez déclaré que un joueur n'a pas besoin de connaître la carte pour lui envoyer des événements, mais votre exemple implique qu'un ennemi mourant doit connaître toutes les autres parties du programme qui doivent être notifiées. Je me serais attendu à ce qu'il envoie simplement un message du type "Je viens de mourir", puis que votre système de messagerie notifie les auditeurs qui sont intéressés par cet événement (jouer un son, mettre à jour le score, etc.). De cette façon, il semble qu'aucune entité n'ait besoin d'envoyer un tas de messages pour un seul événement (jouer un son, augmenter le score). Ou est-ce que je me suis trompé ?

1 votes

@Groo Je n'ai pas pu raccourcir suffisamment ma réponse, je l'ai donc éditée dans ma réponse ci-dessus.

1 votes

Salut mec, ça fait plus de 5 ans depuis ta réponse, mais ce post est apparu quand je cherchais une idée simple de pubsub, et je dois dire, j'ai téléchargé les sources, et à part les standards de codage auxquels je ne suis pas habitué et le fait que c++ a un peu progressé depuis 2005, le code est très très intéressant à rechercher et j'ai utilisé une partie du squelette de EMS pour mon jeu en C#. Ce que vous avez fait tous les trois est vraiment étonnant et difficile, et j'espère que j'en apprendrai davantage !

15voto

Stephane Rolland Points 8110

Les solutions génériques pour la communication entre objets en évitant le couplage étroit :

  1. Modèle de médiateur
  2. Modèle d'observateur

1 votes

Le modèle du médiateur se trouve dans le MVC (où le contrôleur est le médiateur). +1 pour le modèle Observer. Fortement utilisé dans certaines plateformes.

0 votes

Hmmm D'après l'article dont vous avez donné le lien, Relationship Manager A première vue, ça sent un peu mauvais, mais ça semble être un objet divin. Il est censé être une sorte de singleton, qui sait tout sur tout le monde. L'article montre les méthodes membres des objets individuels ( Customer.AddOrder , Customer.RemoveOrder ) exposant leurs internes au "manager" et permettant ensuite au manager de faire le travail pour eux. Où est passée la POO alors ? De plus, afin de tester l'ajout d'une seule commande à un client, vous êtes censé simuler la classe entière du gestionnaire. Je préférerais que vous ne gardiez que les deux premiers liens.

0 votes

Belle remarque de votre part. Je supprime le lien ;-).

5voto

Martin Points 197

Voici un système d'événements soigné écrit pour C++11 que vous pouvez utiliser. Il utilise des templates et des pointeurs intelligents ainsi que des lambdas pour les délégués. C'est très flexible. Vous trouverez également un exemple ci-dessous. Envoyez-moi un courriel à info@fortmax.se si vous avez des questions à ce sujet.

Ces classes vous offrent un moyen d'envoyer des événements auxquels sont attachées des données arbitraires et un moyen facile de lier directement des fonctions qui acceptent des types d'arguments déjà convertis que le système convertit et vérifie pour une conversion correcte avant d'appeler votre délégué.

Fondamentalement, chaque événement est dérivé de la classe IEventData (vous pouvez l'appeler IEvent si vous voulez). À chaque "trame", vous appelez ProcessEvents(). À ce moment-là, le système d'événements passe en revue tous les délégués et appelle les délégués qui ont été fournis par d'autres systèmes qui ont souscrit à chaque type d'événement. Chacun peut choisir les événements auxquels il souhaite s'abonner, car chaque type d'événement possède un identifiant unique. Vous pouvez également utiliser des lambdas pour vous abonner à des événements comme ceci : AddListener(MyEvent::ID(), [&](shared_ptr ev){ faites votre truc }

Quoi qu'il en soit, voici la classe avec toute l'implémentation :

#pragma once

#include <list>
#include <memory>
#include <map>
#include <vector>
#include <functional>

class IEventData {
public:
    typedef size_t id_t; 
    virtual id_t GetID() = 0; 
}; 

typedef std::shared_ptr<IEventData> IEventDataPtr; 
typedef std::function<void(IEventDataPtr&)> EventDelegate; 

class IEventManager {
public:
    virtual bool AddListener(IEventData::id_t id, EventDelegate proc) = 0;
    virtual bool RemoveListener(IEventData::id_t id, EventDelegate proc) = 0; 
    virtual void QueueEvent(IEventDataPtr ev) = 0; 
    virtual void ProcessEvents() = 0; 
}; 

#define DECLARE_EVENT(type) \
    static IEventData::id_t ID(){ \
        return reinterpret_cast<IEventData::id_t>(&ID); \
    } \
    IEventData::id_t GetID() override { \
        return ID(); \
    }\

class EventManager : public IEventManager {
public:
    typedef std::list<EventDelegate> EventDelegateList; 

    ~EventManager(){
    } 
    //! Adds a listener to the event. The listener should invalidate itself when it needs to be removed. 
    virtual bool AddListener(IEventData::id_t id, EventDelegate proc) override; 

    //! Removes the specified delegate from the list
    virtual bool RemoveListener(IEventData::id_t id, EventDelegate proc) override; 

    //! Queues an event to be processed during the next update
    virtual void QueueEvent(IEventDataPtr ev) override; 

    //! Processes all events
    virtual void ProcessEvents() override; 
private:
    std::list<std::shared_ptr<IEventData>> mEventQueue; 
    std::map<IEventData::id_t, EventDelegateList> mEventListeners; 

}; 

//! Helper class that automatically handles removal of individual event listeners registered using OnEvent() member function upon destruction of an object derived from this class. 
class EventListener {
public:
    //! Template function that also converts the event into the right data type before calling the event listener. 
    template<class T>
    bool OnEvent(std::function<void(std::shared_ptr<T>)> proc){
        return OnEvent(T::ID(), [&, proc](IEventDataPtr data){
            auto ev = std::dynamic_pointer_cast<T>(data); 
            if(ev) proc(ev); 
        }); 
    }
protected:
    typedef std::pair<IEventData::id_t, EventDelegate> _EvPair; 
    EventListener(std::weak_ptr<IEventManager> mgr):_els_mEventManager(mgr){

    }
    virtual ~EventListener(){
        if(_els_mEventManager.expired()) return; 
        auto em = _els_mEventManager.lock(); 
        for(auto i : _els_mLocalEvents){
            em->RemoveListener(i.first, i.second); 
        }
    }

    bool OnEvent(IEventData::id_t id, EventDelegate proc){
        if(_els_mEventManager.expired()) return false; 
        auto em = _els_mEventManager.lock(); 
        if(em->AddListener(id, proc)){
            _els_mLocalEvents.push_back(_EvPair(id, proc)); 
        }
    }
private:
    std::weak_ptr<IEventManager> _els_mEventManager; 
    std::vector<_EvPair>        _els_mLocalEvents; 
    //std::vector<_DynEvPair> mDynamicLocalEvents; 
}; 

Et le fichier Cpp :

#include "Events.hpp"

using namespace std; 

bool EventManager::AddListener(IEventData::id_t id, EventDelegate proc){
    auto i = mEventListeners.find(id); 
    if(i == mEventListeners.end()){
        mEventListeners[id] = list<EventDelegate>(); 
    }
    auto &list = mEventListeners[id]; 
    for(auto i = list.begin(); i != list.end(); i++){
        EventDelegate &func = *i; 
        if(func.target<EventDelegate>() == proc.target<EventDelegate>()) 
            return false; 
    }
    list.push_back(proc); 
}

bool EventManager::RemoveListener(IEventData::id_t id, EventDelegate proc){
    auto j = mEventListeners.find(id); 
    if(j == mEventListeners.end()) return false; 
    auto &list = j->second; 
    for(auto i = list.begin(); i != list.end(); ++i){
        EventDelegate &func = *i; 
        if(func.target<EventDelegate>() == proc.target<EventDelegate>()) {
            list.erase(i); 
            return true; 
        }
    }
    return false; 
}

void EventManager::QueueEvent(IEventDataPtr ev) {
    mEventQueue.push_back(ev); 
}

void EventManager::ProcessEvents(){
    size_t count = mEventQueue.size(); 
    for(auto it = mEventQueue.begin(); it != mEventQueue.end(); ++it){
        printf("Processing event..\n"); 
        if(!count) break; 
        auto &i = *it; 
        auto listeners = mEventListeners.find(i->GetID()); 
        if(listeners != mEventListeners.end()){
            // Call listeners
            for(auto l : listeners->second){
                l(i); 
            }
        }
        // remove event
        it = mEventQueue.erase(it); 
        count--; 
    }
}

Par souci de commodité, j'utilise une classe EventListener comme classe de base pour toute classe qui souhaite écouter les événements. Si vous dérivez votre classe d'écoute de cette classe et que vous lui fournissez votre gestionnaire d'événements, vous pouvez utiliser la fonction très pratique OnEvent(..) pour enregistrer vos événements. Et la classe de base désinscrira automatiquement votre classe dérivée de tous les événements lorsqu'elle sera détruite. C'est très pratique car si vous oubliez de supprimer un délégué du gestionnaire d'événements lorsque votre classe est détruite, votre programme se plantera presque certainement.

Un moyen efficace d'obtenir un identifiant unique pour un événement en déclarant simplement une fonction statique dans la classe, puis en convertissant son adresse en un int. Puisque chaque classe aura cette méthode sur différentes adresses, elle peut être utilisée pour une identification unique des événements de la classe. Vous pouvez également convertir typename() en int pour obtenir un identifiant unique si vous le souhaitez. Il y a plusieurs façons de le faire.

Voici donc un exemple d'utilisation :

#include <functional>
#include <memory>
#include <stdio.h>
#include <list>
#include <map>

#include "Events.hpp"
#include "Events.cpp"

using namespace std; 

class DisplayTextEvent : public IEventData {
public:
    DECLARE_EVENT(DisplayTextEvent); 

    DisplayTextEvent(const string &text){
        mStr = text; 
    }
    ~DisplayTextEvent(){
        printf("Deleted event data\n"); 
    }
    const string &GetText(){
        return mStr; 
    }
private:
    string mStr; 
}; 

class Emitter { 
public:
    Emitter(shared_ptr<IEventManager> em){
        mEmgr = em; 
    }
    void EmitEvent(){
        mEmgr->QueueEvent(shared_ptr<IEventData>(
            new DisplayTextEvent("Hello World!"))); 
    }
private:
    shared_ptr<IEventManager> mEmgr; 
}; 

class Receiver : public EventListener{
public:
    Receiver(shared_ptr<IEventManager> em) : EventListener(em){
        mEmgr = em; 

        OnEvent<DisplayTextEvent>([&](shared_ptr<DisplayTextEvent> data){
            printf("It's working: %s\n", data->GetText().c_str()); 
        }); 
    }
    ~Receiver(){
        mEmgr->RemoveListener(DisplayTextEvent::ID(), std::bind(&Receiver::OnExampleEvent, this, placeholders::_1)); 
    }
    void OnExampleEvent(IEventDataPtr &data){
        auto ev = dynamic_pointer_cast<DisplayTextEvent>(data); 
        if(!ev) return; 
        printf("Received event: %s\n", ev->GetText().c_str()); 
    }
private:
    shared_ptr<IEventManager> mEmgr; 
}; 

int main(){
    auto emgr = shared_ptr<IEventManager>(new EventManager()); 

    Emitter emit(emgr); 
    {
        Receiver receive(emgr); 

        emit.EmitEvent(); 
        emgr->ProcessEvents(); 
    }
    emit.EmitEvent(); 
    emgr->ProcessEvents(); 
    emgr = 0; 

    return 0; 
}

4voto

kellogs Points 1220

Le modèle MVC (modèle-vue-contrôleur) et la pompe à messages que vous avez suggérée sont tout ce dont vous avez besoin.

"Ennemi" et "Joueur" entreront probablement dans la partie Modèle de MVC, cela n'a pas beaucoup d'importance, mais la règle générale est que tous les modèles et vues interagissent via le contrôleur. Ainsi, vous voudrez garder des références (mieux que des pointeurs) vers (presque) toutes les autres instances de classe à partir de cette classe "contrôleur", appelons-la ControlDispatcher. Ajoutez-lui une pompe à messages (cela dépend de la plate-forme pour laquelle vous codez), instanciez-la en premier (avant toute autre classe et intégrez les autres objets) ou en dernier (et stockez les autres objets comme références dans ControlDispatcher).

Bien sûr, la classe ControlDispatcher devra probablement être divisée en contrôleurs plus spécialisés afin de maintenir le code par fichier à environ 700-800 lignes (c'est la limite pour moi, du moins) et il peut même y avoir plus de threads qui pompent et traitent les messages en fonction de vos besoins.

Cheers

0 votes

+1 Il n'est pas nécessaire de réinventer les choses, je suis d'accord.

-1voto

EnabrenTane Points 5262

La suggestion de @kellogs concernant le MVC est valable, et utilisée dans quelques jeux, bien que son beaucoup plus fréquents dans les applications et les frameworks web. C'est peut-être un peu trop pour ce type d'application.

Je repenserais votre conception, pourquoi le joueur doit-il parler aux ennemis ? Ne pourraient-ils pas tous deux hériter d'une classe Acteur ? Pourquoi les Acteurs ont-ils besoin de parler à la Carte ?

En lisant ce que j'ai écrit, ça commence à rentrer dans un cadre MVC... J'ai manifestement trop travaillé sur les rails ces derniers temps. Cependant, je suis prêt à parier qu'ils n'ont besoin de savoir que des choses comme qu'ils entrent en collision avec un autre Acteur, et qu'ils ont une position, qui devrait être relative à la carte de toute façon.

Voici une mise en œuvre de Astéroïdes sur lequel j'ai travaillé. Votre jeu peut être, et est probablement, complexe.

0 votes

Le joueur et l'ennemi ont besoin de connaître la carte pour naviguer, ce n'était qu'un exemple grossièrement simplifié.

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X