Praktikum Design und Entwicklung eines Computerspiels
--------
Netzwerk: Serialisierung von Aktionen und Entities
Version 0.9.2

Thomas Huth

Abstract: Dieser Artikel entstand während des Praktikums ''Design und Entwicklung eines Computerspiels'' und beschreibt die Serialisierung von Entities/Aktionen für deren Transport übers Netzwerk bzw. für deren Abspeicherung in einer Datenbank etc.

1  Serialisierung

1.1  Einleitung

Entities und Aktionen in Neverending-ODC müssen komplett und auch teilweise über das Netzwerk übertragen werden können. Hierfür ist es erforderlich, die Entities- und Action-Klassen in eine ,,flache'', d.h. serialisierte Struktur zu bringen, so dass die Netzwerkschicht sie leicht in einen Bytestrom zum Übertragen über das Netz umwandeln kann.

Aber nicht nur für die Netzwerkschicht ist so eine Serialisierung angebracht, auch zum Abspeichern der Entities, z.B. in einer Datenbank oder in eine XML-Datei, ist ein eine solche Datenrepräsentation der Objekte hilfreich. Deshalb soll in Neverending-ODC ein möglichst universell einsetzbares System zur Serialisierung auf Entity/Action-Ebene zum Einsatz kommen, erst die entsprechenden Komponenten (Netzwerk, XML-Dateispeicherklasse, etc.) machen dann aus diesem universellen Schema die tatsächlich benötigte Datendarstellung (Bytestrom, XML, etc.).

1.2  Vorgehensweise

Hauptidee des hier vorgestellten Serialisierungsansatzes ist es, dass jedes zu serialisierende Objekt zwei Methoden zur Verfügung stellt, die je einen Vektor mit den gewünschten Daten zurückliefern. Ein Vektoreintrag beinhaltet dann den Namen der entsprechenden Variablen, den Typ der Variablen und natürlich auch noch den Wert der Variablen.

Als Namen für die beiden Methoden werden im Folgenden getCondensedDataVector und getAllCondensedDataVector verwendet. Die eine Methode dient dabei nur dazu, einen Teil des Objektes zu serialisieren, die andere hat die Aufgabe, das komplette Objekt zu serialisieren. Die erste erwähnte Methode ist deshalb nötig, weil es nicht immer sinnvoll ist, ein komplettes Objekt zu serialisieren. Wenn man z.B. nur ein Positions-Update eines Entities verschicken will, ist es nicht nötig, auch noch die Rotation und weitere Entity-Eigenschaften mit zu verschicken.

1.2.1  Der Serialisierungsvektor

Um die Daten im Rückgabevektor der beiden Methoden sinnvoll weiterverarbeiten zu können, müssen mindestens folgende Informationen in einem Vektoreintrag enthalten sein: Als Typ der Variablen werden folgende Konstanten benutzt, die durch eine Aufzählung (enum) definiert sind:
/** This enum declares the constants for types of the serialization
    vector entries */
enum CondensedDataType
{
    INT8,                   /* 8 bit integer */
    INT16,                  /* 16 bit integer */
    INT32,                  /* 32 bit integer */
    FLOAT,                  /* single precission float */
    DOUBLE,                 /* double precission float */
    CSTRING,                /* a C string */
    VECTOR3D                /* a Vector3d object */
};
Der Vektor, der die Variablenwerte in der gewünschten Form enthält, wird nun wie folgt definiert:
#include <string>
#include <vector>
#include <odcTypes.h>

class CondensedData
{
    string m_varName;           /* The name of the variable */
    CondensedDataType m_type;
    union
    {
        OdcSInt8 m_int8;
        OdcSInt16 m_int16;
        OdcSInt32 m_int32;
        float m_float;
        double m_double;
        char* m_cstring;
        Vector3d* m_vector3d;
    } m_value;
};

typedef std::vector<CondensedData *> CondensedDataVector;
Man beachte hierbei, dass nicht die C/C++ Standarddatentypen ,,char'', ,,short'', ,,int'' und ,,long'' in m_value benutzt werden, sondern größenspezifische Datentypen, da die tatsächliche Bytegröße eines solchen Eintrags sehr wichtig für das Netzwerk ist, aber die C/C++ Standarddatentypen je nach Compiler bzw. Systemarchitektur unterschiedliche Bytegrößen haben.

,,Nicht-primitive'' Datentypen wie z.B. Arrays oder Structs, müssen für die Serialisierung in primitive Datentypen umwandelbar sein. So kann z.B. ein Arrays für die Position a la ,,float xyz[3];'' in seine drei einzelnen Koordinatenanteile aufgeteilt werden. Auf eine sinnvolle Namensgebung für m_varName ist hierbei besonders zu achten .

1.2.2  Die Zugriffsmethoden

Die Prototypen der Zugriffsmethoden sehen wie folgt aus:
CondensedDataVector
    getCondensedDataVector(std::vector<string *> f_StrVec);
liefert den zugehörigen Datenvektor zu den in f_StrVec angegebenen Variablennamen.
CondensedDataVector getAllCondensedDataVector(void);
liefert einen Vektor mit allen zu dem Objekt gehörigen Daten.

Die Realisierung dieser Methoden geschieht wie folgt: In einer obergeordneten Serialisierungsklasse (namens DataCondenser), von der alle Objekte erben müssen, welche die Serialisierungsfunktionen bieten sollen, wird eine ,,map'' (bzw. ,,hash_map'') definiert, die wie folgt aussieht:
#include <hashMap.h>

class CondensedDataRef
{
    CondensedDataType m_type;
    union                     /* The reference to the variable */
    {
        OdcSInt8* m_int8;
        OdcSInt16* m_int16;
        OdcSInt32* m_int32;
        float* m_float;
        double* m_double;
        char** m_cstring;
        Vector3d* m_vector3d;
    } m_ref;
};

MAKE_MAP_CSTR(CondensedDataRef*, CondensedDataRefMap);
Im Konstruktor eines serialisierbaren Entities- bzw. Action-Objektes wird dann für jede betroffene Variable ein CondensedDataRef erzeugt, m_type auf den entsprechenden Typ gesetzt und den Pointer m_ref lässt man auf die entsprechende Variable zeigen. Das fertig initialisierte CondensedDataRef fügt man dann mit dem Variablennamen als Schlüssel in die CondensedDataRefMap des Objektes ein.

Die Zugriffsmethoden getCondensedDataVector() und getAllCondensedDataVector() können dann einfach und überall auf die gleiche Weise über diese ,,map'' die entsprechenden Variablenwerte erreichen, so dass es im Normalfall reicht, diese beiden Zugriffsfunktione in der Eltern-Serialisierungsklasse bereit zu stellen und in den implementierenden Klassen zu benutzen. Der Zugriff funktioniert dann bei allen Erb-Klassen genau gleich über die ,,map''. Die Methode getCondensedDataVector() kann dabei direkt über die Variablennamen-Strings auf die ,,map'' zugreifen und die Methode getAllCondensedDataVector() kann die ,,map'' einfach mit einem ,,iterator'' durchlaufen.

Falls die implementierende Entity- bzw. Aktionsklasse auch die Daten ihres Elternobjektes mitliefern möchte, muss darauf geachtet werden, dass beim Initialisieren der CondensedDataRefMap die Daten der Eltern-Klasse(n) erhalten bleiben. Damit es beim Vererben keine Namenskonflike gibt, ist bei der Bildung der Vererbungshierarchie darauf zu achten, dass die zu serialisierenden Klassenvariablennamen eindeutig sind!

1.2.3  Die Methoden zum Setzen

Analog zu den beiden beschriebenen Methoden zum Abfragen von Objektvariablen muss es natürlich auch ein Gegenteil dazu geben, eine Methode, welche die Werte eines Objektes aus einem CondensedDataVector ausliest und im Objekt die entsprechenden Variablen auf diese Werte setzt.

Der Prototyp dieser Methode sieht wie folgt aus:
int setCondensedData(CondensedDataVector& f_dataVec);
Diese Methode kann analog zu getCondensedDataVector() auch über die CondensedDataRefMap des Objektes leicht realisiert werden.

Falls eine Klasse jedoch darauf angewiesen ist, Änderungen an einem Variablenwert mitzubekommen, muss die Methode setCondensedData() in der Klasse überschrieben werden, um den übergebenen Datenvektor auf entsprechende Variablen hin zu überprüfen. Natürlich sollte in diesem Fall in der Methode auch unbedingt die setCondensedData() der Elternklasse aufgerufen werden, um den Rest des Datenvektors zu verarbeiten.

1.3  Ausblicke

Der unschönste Teil an diesem Konzept ist bisher die Tatsache, dass die CondensedDataRefMap im Konstruktor eines Objektes mühsam ,,von Hand'' erstellt werden muss. Ändert man eine Klassenvariable, muss auch die ,,map'' im Konstruktor von Hand geändert werden.

Sinnvoll wäre es, hier einen Präprozessor (bzw. eine bestimmte Make-Regel und ein geeignetes Tool wie z.B. Flex?) einzusetzen, der aus den Klassenvariablen (welche z.B. durch einen bestimmten Ausdruck markiert sind) diese CondensedDataRefMap im Konstruktor automatisch erstellt.