Listen unterschiedlicher Klassen speichern und laden (Serialisierung)
In den meisten Spielen müssen irgendwelche Daten im Spiel ist Daten-Serialisierung erforderlich, also das Speichern und Laden von eigenen Werten. Das kann zum Beispiel der Spielfortschritt sein oder auch Level-Karten, wie man sie üblicherweise für Gelegenheitsspiele braucht. Es ist zwar möglich, aber sehr mühsam und schlecht wartbar, wenn man für ein Casual-Game jedes Level als Szene in Unity anlegt. Immerhin ist die Spielmechanik im Wesentlichen ja einheitlich und nur die aktiven Features oder Details wie das Spielbrettlayout variieren im Spielverlauf. Da ich gerade an einem solchen Mobile-Game arbeitete, war meine Idee deshalb, das Spiel so zu programmieren, dass die konkreten Level in Level-Dateien hinterlegt werden. Es sollte sich um ein schlichtes und kompaktes Format handeln, das so wenig Daten wie möglich transportieren muss. Zudem sollen Leereinträge vermieden werden. Wenn in einem Level etwas nicht vorkommt, dann soll es in der Datei (und dann in den aus der Datei generierten Datenobjekten) auch nicht vorkommen. Also nicht etwa eine Variable die auf Null zeigt, sondern wirklich gar keine Existenz des Datenelements.
Die Standard-XML-Serialisierung produziert sehr viel Text
Mangels Erfahrung hatte ich im Projekt A Room Beyond einige Klassen so aufgesetzt, dass am Schluss viele Situationen entstanden in denen Objekte leere Felder hatten, weil die entsprechende Eigenschaft oder Funktion nur in einzelnen Szenen benötigt, aber in einer an vielen Stellen eingesetzten Klasse implementiert wurde. Auch die Savegames, die in XML formuliert wurden, waren unnötig aufgebläht. Diese Problem entstand vor allem dadurch, dass die Standard-XML-Serialisierung sehr gerne verschachtelte Objekte generiert.
<sl k="enbl"> <v xsi:type="xsd:boolean">false</v> </sl>
In dem oben eingefügten Beispiel sieht man, dass die Eigenschaft enabled=false
für ein Objekt in der XML-Serialisierung recht ausführlich formuliert wird. Optimiert sähe das Fragment zumindest ungefähr so aus:
<sl enbl="false" />
Der Variablen-Name wird in diesem Beispiel direkt als XML-Attribut-Name verwendet und der Wert als Attribut-Wert übernommen. Der alte Code ist deshalb erheblich größer, weil ein zusätzliches Unterelement v
eingesetzt wurde und dadurch wiederum der umschließende Tag sl
doppelt so groß wird, da er einen Abschluss mit /sl
braucht.
Der Teufel steckt im Detail
Das Problem bei der Sache ist die Typisierung. Während beim dynamisch typisierten JavaScript zunächst egal wäre, ob die gespeicherte Eigenschaft eine Zahl, Text oder wahr/falsch ist, ist das bei C# nicht so einfach. Um Objekt und ihre Eigenschaften später wieder laden zu können, müssen wir entweder fest definierte Felder verwenden, also zum Beispiel eine Klasse erzeugen, die genau definierte Felder hat. Dann könnte der Deserialisierer beim Laden in die Klasse schauen und daraus den Typ identifizieren. Nun kann es aber andererseits sein, dass wir eben keine festen Felder vorgeben wollen, sondern im Prinzip jederzeit beliebige Daten dem Spielstand hinzufügen/entfernen können wollen. Dann muss der Datentyp mit gespeichert werden, damit der Deserialisierer später weiß, um welche Art von Inhalt es sich handelt. Und hier wird es kompliziert.
Die Wunschvorstellung: Eine Liste, gefüllt mit egal-was
Idealerweise wäre es so: Man legt eine Instanz einer Liste an und fügt ihr beliebige Klassen hinzu. Man speichert und lädt einfach die gesamte Liste. Der Vorteil wäre dabei, dass man in den Datenklassen beliebige Felder umsetzen kann und dabei nur die Werte gespeichert werden müssen, da sich die Typen ja schon aus der Klassendeklaration ergeben.
public class A { public int feldVonA = 333; } public class B { public int feldVonB = 0; } public List items = new List(); items.add(new A()); items.add(new B());
Es tauchen einige Problem auf. Zunächst muss der Liste ein Typ zugewiesen werden, das geht relativ einfach, indem wir A
und B
eine Superklasse X
zuweisen und diese als Listentyp anwenden.
public class X{} public class A:X { public int feldVonA = 333; } public class B:X { public int feldVonB = 0; } public List<X> items = new List<X>(); items.add(new A()); items.add(new B());
Versucht man jetzt, diese Liste mit dem System.XML
Serialisierer zu speichern, wird dies zunächst nicht funktionieren, weil die Listenelemente alle über ihre Superklasse X
behandelt werden und daher nicht zwischen A und B unterschieden werden. Dies wird durch den Fehler The type of the argument object ‚A‘ is not primitive. beschrieben.
Die Lösung, um eine Liste mit unterschiedlichen Klasseninstanzen nach XML zu serialisieren
Der Trick liegt in Meta-Attributen, Erweiterungen des Source-Codes um Descriptoren, die bestimmte Hinweise zu Interpretation und Verarbeitung geben. Für die Speicherung in XML können wir zum Beispiel das XMLElement-Attribut vor ein Feld schreiben, und so dem XML-Serialisierer sagen, wie der Tag heißen soll mit dem das Objekt in XML gespeichert wird:
[XmlElement("MyElementName")] public class A:X
…resultiert in…
<MyElementName ...>
Dieses Attribut lässt sich nun auch auf Listen anwenden. Das folgende Beispiel zeigt, wie sich alle Elemente einer Liste (hier DataBlock
-Instanzen) in der XML-Ausgabe umbenennen lassen (und zwar von <DataBlock>
zu <d>
):
//[XmlArray("r")] //-> wrapper <r> containing child elements <DataBlock> [XmlElement("d")] //-> direct children, each named <d> (instead of DataBlock) public List<DataBlock> raw = new List<DataBlock>();
Der Trick für unsere heterogene Liste besteht nun darin, dass man dieses Attribut für Listen auch stapeln kann! Dabei werden die Parameter erweitert: Neben dem gewünschten Namen für das XML-Element wird zusätzlich noch die Klasse angegeben für die diese Benennung gilt:
[XmlElement("A", typeof(A))] [XmlElement("B", typeof(B))] public List<X> items = new List<X>();
Durch diese Zuweisung weiß das Serialisierungssystem nun, welche Klasse zu welchem XML-Element gehört und kann die gemischte Liste speichern und laden.
Komprimierung der XML-Darstellung
Am Anfang dieses Artikels habe ich über die Ausführlichkeit gesprochen mit der XML-Darstellungen im Normalfall erzeugt werden. Mit Attributen lässt sich auch dies beeinflussen:
public class A:X { //[XmlAttribute("sf")] public int subfield = 333; } /* XML Serialization: <?xml version="1.0" encoding="utf-8"?><xml xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema"><A><subfield>333</subfield></A><B ixi="0" /></xml> */
…während…
public class A:X { [XmlAttribute("sf")] public int subfield = 333; } /* XML Serialization: <A sf="333" /> */
Andere Ansätze zum Speichern und Laden von Daten in Unity
ScriptableObject
Sehr bequem, da man sich so gut wie garnicht um Speichern und Laden kümmern muss. Nachteil: Speichern funktioniert nur im Editor, da das Format als benutzerdefiniertes Asset-Format gedacht ist. Speichern von ScriptableObjects im fertigen Spiel geht nicht.JSON
Mit Unity’s relativ neuer KlasseJsonUtility
können Objekte sehr einfach über das JSON-Format dargestellt werden. Nachteile: Der Serialisierungsvorgang kann nicht wie bei XML, beeinflusst oder erweitert werden, Sonderfälle sind damit nicht abgedeckt. Mit Listen hat es in meinen Versuchen überhaupt nicht funktioniert und wegen der zuvor genannten Statik kann man daran auch nicht viel machen.