23 maj 2008

Studieflykt igen

Då var det tenta-p igen, och dags att bli entusiastisk. Inför alla hobbyprojekt som har legat och skräpat sedan förra tenta-p alltså. Den här gången har mitt spelprojekt fått en renässans.

Tänkte att jag skulle gå och bli lite teknisk nu. Det är ju ändå bara C-are som läser den här bloggen (om någon, nu för tiden). Det finns väldigt mycket små saker att tänka på när man vill bygga en gedigen spelmotor. När man inte längre nöjer sig med fulhack börjar man leta vitt och brett över internet efter snygga lösningar.

Ett problem är hur man vill spara alla dynamiska objekt på hårddisken mellan spel. Det vill säga all data om kartor, monster, animationer, spelardata och så vidare. Den vanligaste lösningen är att på något sätt "serialisera" ett objekt, svd skriva de enskilda datumen (jo, faktiskt) som objektet består av - tal, tecken eller andra enkla datatyper som i slutänden bara är enskilda bytes - i en bestämd ordning så att man på ett kontrollerat sätt kan läsa in dem igen när objektet ska återskapas. Det här är den i mitt tycke estetiskt snyggaste lösningen på detta problem som jag har hittat. I huvuddrag fungerar den så här:

* Kräver speciell implementation i varje enskild klass som ska kunna serialiseras, men på ett snyggt och läsbart sätt.
* Ger dig kontroll över exakt hur datan skrivs till fil, vilket gör det möjligt att byte-hacka ihop egna datafiler för testning.
* Kapslar in all filhantering.
* Serialiserar på djupet, det vill säga en Serializable kan äga andra Serializables som automatiskt skrivs till fil när det ägande objektet gör det, utan att bryta inkapsling.

Huvuddragen i det här designmönstret finns beskrivna här, men jag tänker inte gå igenom i detalj hur alla klasser och metoder som behövs är implementerade. Om du vill grotta ner dig i ämnet och använda metoden i din egen kod så rekommenderar jag denna utmärkta artikel, som jag själv har tagit idén från.

Mönstret bygger på tre "pure virtual" klasser (eller "abstrakta klasser" eller "interface"), Serializer, DeSerializer och Serializable, enligt nedan.
class Serializer {
public:
virtual void PutLong (long l) = 0;
virtual void PutDouble (double d) = 0;
virtual void PutString (std::string const & str) = 0;
virtual void PutBool (bool b) = 0;
};

class DeSerializer {
public:
virtual long GetLong() = 0;
virtual double GetDouble() = 0;
virtual std::string GetString() = 0;
virtual bool GetBool() = 0;
};

class Serializable {
public:
virtual void Serialize(Serializer *out) = 0;
virtual void DeSerialize(DeSerializer *in) = 0;
};
Bara den här koden ger sig kanske ett hum om konturen på lösningen. Serializer har ansvaret att öppna en fil och skriva enskilda enkla datatyper till den. DeSerializer fungerar på samma sätt, men läser istället in data. Varje klass som ärver av dessa två måste implementera de fyra metoder de deklarerar, plus en konstruktor och en destruktor. Konstruktorn tar ett filnamn som argument och öppna själva filen (i antingen read- eller append-läge) som data ska skrivas till, medan destruktorn, naturligt nog, stänger den igen.

Det finaste med den här kråksången är att det går att implementera Serializer och DeSerializer på många olika sätt. Du kan ha en Serializer för att skriva till filer, en för att skriva till ett minnesutrymme, och en som bara räknar hur många bytes som skulle gå åt OM man skulle skriva objektet till fil, utan att skriva någonting alls själv. För mitt projekt gjorde jag ett eget Serializer/DeSerializer-par för att skriva och läsa filer med hjälp av PHYSFS, ett filinläsningsbibliotek som jag använder för att läsa och skriva zip-filer.

Ansvaret för varje klass som ärver av Serializable är att implementera de båda metoderna Serialize och DeSerialize så att de skriver och läser data på rätt sätt för just den enskilda klassen. (Det vackra är förstås att dessa metoder inte behöver känna till vilken typ av Serializer eller DeSerializer som faktiskt används.) En av mina mest grundläggande klasser som ärver av Serializable är Sprite. Här är en del av dess klassdeklaration:
#include "Serializable.h"
class Serializer;
class DeSerializer;

class Sprite : public Serializable {
public:
void Serialize(Serializer *out);
void DeSerialize(DeSerializer *in);
[...]
private:
[...]
};
Så när jag vill skriva ett sprite-objekt till en fil kan det se ut så här:
Serializer *fss = new FsSerializer("spritefil.dat");
Sprite s();
// [Initiera sprite med data här]
s.Serialize(fss);
Själva skrivandet av objektet har reducerats till en enda rad kod. Najs. När jag vill läsa in objektet igen gör jag så här:
DeSerializer *fsds = new FsDeSerializer("spritefil.dat");
Sprite s();
s.DeSerialize(fss);
//Färdigt Sprite-objekt fyllt med data!
Objektet är nu återställt och klart. Så här är Serialize implementerad i Sprite-klassen:
void Sprite::Serialize(Serializer *out) {
out->PutLong(mSpriteId);
out->PutLong(mDelay);
out->PutLong(mAnimations.size());
for (int i = 0; i <>
mAnimations[i]->Serialize(out);
}
}
I varje implementation av Serialize behöver jag bara koncentrera mig på ett enda objekt, men som ni ser är det inget problem att skriva många objekt efter varandra, så länge varje objekt själv håller reda på hur stort det är. Om jag nöjer med att alltid läsa in filer seriellt, från början till slut, behöver jag ingen mer funktionalitet än så här. vill jag kunna göra lite mer "random access" i filer behövs två saker till: en metod Search(int placeInFile) i min DeSerializer och ett filhuvud för att hålla reda på var i filen olika objekt finns.

För att skriva många spritar till en fil gör jag en klass som heter SpriteFileHeader och som även den ärver av Serializable. Huvudet innehåller en mappning mellan ID-nummer för spritar och index för var i filen de olika spritarna finns. Huvudet är sedan alltid det första som läses in från en ny fil. När det väl är avserialiserat kan jag använda Search-metoden för att hoppa till vilken sprite i filen jag vill.

Hur lätt det är att implementera Search beror så klart på vilken typ av DeSerializer du har gjort. Min FsDeSerializer läser in HELA filen till minnet så fort den skapas och representerar den som en bytebuffer, vilket gör det lätt. Om du använder filströmmar kommer det att se annorlunda ut.

Vi ber om ursäkt för att vi haft problem med indenteringsmaskinen under detta blogginlägg.

2 Comments:

Blogger olsner said...

Ah! Det klassiska de/serialiseringsproblemet ;-)

Senast jag gjorde ett försök (2004, iirc) blev det något mer i stil med detta (ursäkt för bristande kodformatering i kommentarer):

/**
* An interface for serializable objects. For a serializable object to be usable
* as a network message field, it must have a constructor without arguments.
*/
class ISerializable
{
public:
/**
* Return the length of the serialized form of this object
*/
virtual uint GetSerializedLength() const = 0;
/**
* Serialize the object into the passed buffer.
*
* @return a pointer to the location in the buffer right after the
* serialized object
*/
virtual u8 *Serialize(u8 *buffer) const = 0;
/**
* Deserialize the object (i.e. read in data from the buffer and initialize
* the object's fields). Note that it is up to the deserializer to detect
* errors in data format.
*
* @param buffer A pointer pointing to the start of the serialized data.
* @param end A pointer to the end of the message.
*
* @returns a pointer to the location in the buffer right after the
* serialized object, or NULL if there was a data format error
*/
virtual const u8 *Deserialize(const u8 *buffer, const u8 *end) = 0;
};

(och här är visserligen vissa saker anpassade för att användaren i det här fallet alltid har/skapar en buffert som är stor nog för hela det serialiserade objektet...) Nu när jag lärt mig haskell skulle jag nog skrivit det som en monad istället - och låta samma monad tolkas på olika sätt för att köra den mot en filström, sträng, förallokerad buffer, eller en null-buffert som bara räknar platsåtgång.

24 maj, 2008 03:28  
Blogger Innie said...

Huff, det är ju inte alls bara C-are som läser bloggen! Hur 17 ska en stackars borttappad SSK'a klara av det här :O

Knaskram till dej

28 maj, 2008 22:59  

Skicka en kommentar

<< Home