Artykuł ten jak sama nazwa wskazuje będzie traktował o tym jak napisać procedury do zapisu / odczytu save game, w sumie artykuł ten można również uogulnić na zapis dowolnych danych w dowolnym programie. Na początek napiszę co osoba czytająca tego artsa powinna wiedzieć aby nie miała większych problemów ze zrozumieniem tekstu:
# umiejętność programowania obiektowego,
# znajomość / doświadczenie z zakresu dziedziczenia,
# podstawy o klasie TStream.
Ok, no to zaczynamy, to co opisuje jest moją prywatną propozycją podejścia do problemu i nie twierdze, że jest to rozwiązanie jedyne słuszne i najlepsze ale tak właśnie zrobiłem w swojej gierce i może to się komuś innemu przydać. Po pierwsze wszystkie obiekty w grze, które będziemy chcieli zapisywać powinny dziedziczyć po jednej głównej klasie, która nazwiemy TSavableObject ale zanim się nią zajmiemy zadbajmy o klasę którą będziemy dokonywać zapisu i odczytu z dysku, a nazwiemy ją TMyFileStream, i zadeklarujemy ją w następujący sposób:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
{$IfDef Debug} TMyFileStream = Class(TFileStream) public destructor Destroy;override; function Write(const Buffer; Count: Longint): Longint; override; function Read(var Buffer; Count: Longint): Longint; override; procedure LogRChecksum; procedure LogWChecksum; private fRCheckSum: int64; fWCheckSum: int64; end; {$Else} TMyFileStream = TFileStream; {$EndIf} |
Kilka objaśnień, otóż każdy od razu widzi że używamy IfDefów gdyż w czasie debugowania często może nam się przydać dodatkowa wiedza o tym co własnie zapisujemy, a gdy wydajemy produkt finalny już ta wiedza nam nie jest potrzebna i w tym momencie nasz obiekt zamienia się w zwykly TFileStream. Kilka slow o metodach: Write/Read – dziala prawie identycznie jak w TFileStream tyle, że oblicza jeszcze checksuma danych które przewijają się przez niego. LogRChecksum/ LogWChecksum powoduje zapisanie loggera (jakiegoś systemu logowania którego ozywamy) aktualnych wartości zliczonych sum oraz ich wyzerowanie. W destruktorze wyświetlamy obliczone sumy, koniec teorii czas na kod:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 |
{ TMyFileStream } {$IfDef Debug} destructor TMyFileStream.Destroy; begin Writeln( logger, 'R-CheckSum:'+IntToHex(fRCheckSum,8) ); Writeln( logger, 'W-CheckSum:'+IntToHex(fWCheckSum,8) ); inherited; end; procedure TMyFileStream.LogRChecksum; begin Writeln( logger, 'R-CheckSum:'+IntToHex(fRCheckSum,8) ); fRCheckSum := 0; end; procedure TMyFileStream.LogWChecksum; begin Writeln( logger, 'W-CheckSum:'+IntToHex(fWCheckSum,8) ); fWCheckSum := 0; end; function TMyFileStream.Read(var Buffer; Count: Longint): Longint; var t: integer; b: PByte; begin result := inherited read(Buffer, Count); b := @Buffer; for t := 0 to count-1 do begin fRCheckSum := fRCheckSum + b^; Inc(b); end; end; function TMyFileStream.Write(const Buffer; Count: Integer): Longint; var t: integer; b: PByte; begin result := inherited write(Buffer, Count); b := @Buffer; for t := 0 to count-1 do begin fWCheckSum := fWCheckSum + b^; Inc(b); end; end; {$EndIf} |
jak w kodzie widać za logger robi nam zwykła zmienna typy TextFile, która jest tworzona w części inicjującej unita o tak:
1 2 3 4 5 6 7 8 9 10 |
initialization {$IfDef Debug} AssignFile(Logger,'Logger.txt'); rewrite(Logger); {$EndIf} finalization {$IfDef Debug} CloseFile(Logger); {$EndIf} end. |
Ok, mamy już podstawową klasę, którą będziemy wykorzystywali do operacji wejścia / wyjścia. Teraz zapoznajmy się z TSavableObject:
1 2 3 4 5 6 7 8 9 10 |
TSavableObject = class protected {$IfDef Debug} procedure SaveState(const a:TMyFileStream);virtual; procedure LoadState(const a:TMyFileStream);virtual; {$Else} procedure SaveState(const a:TMyFileStream);virtual;abstract; procedure LoadState(const a:TMyFileStream);virtual;abstract; {$EndIf} end; |
Zasada ta sama co wcześniej, w trybie debug chcemy mieć możliwość wyciągnięcia trochę większej liczby informacji, a w trybie release nic nas nie interesuje więc robimy to abstrakcyjne. Ok mamy definicje teraz zerknijmy co mamy w środku:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
{ TSavableObject } {$IfDef Debug} procedure TSavableObject.LoadState(const a: TMyFileStream); begin writeln(Logger, ClassName+'[LoadState] filePos:'+IntToHex(a.Position,6)); end; procedure TSavableObject.SaveState(const a: TMyFileStream); begin writeln(Logger, ClassName+'[SaveState] filePos:'+IntToHex(a.Position,6)); end; {$EndIf} |
Jak widać na załączonym rysunku w trybie debug nasza klasa będzie logowała operacje zapisu/odczytu oraz podawała w którym miejscu coś zapisała, na pierwszy rzut oka może to się wydawać zbędne ale gdy zapisujemy plik, który ma ~400 kb i na dodatek składa się z informacji o ~100 obiektach może nas czasami interesować czy np. kolejność odczytu jest taka sama lub czy w ogóle czytamy wszystkie dane. W oparciu o takie logowanie można porównać pozycje zapisu i odczytu i już wiemy czy zapisujemy tak jak chcemy i czy czytanie zaczyna się od tych samych miejsc. Ok mamy klasę macierzystą, tak jak na początku wspomniałem wszystkie obiekty które chcą coś zapisać powinny po niej dziedziczyć teraz stwórzmy sobie z dwa takie obiekty. Pierwszy nazwiemy TMaster, a drugi TSlave. Obiekt master będzie miał listę obiektów TSlave i będzie je sobie zapisywał / wczytywał a będzie to wyglądało o tak:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
type TRealArray = array[0..30] of Real; TMasterSaveBlock = record fInteger: Integer; fAReal: TRealArray; fSlaveCount: Integer; end; TMaster = class(TSavableObject) public constructor Create; overload; constructor Create(const a: TMyFileStream);overload; destructor Destroy;override; private fSlaves: TList; fInteger: Integer; fAReal: TRealArray; protected procedure SaveState(const a:TMyFileStream);override; procedure LoadState(const a:TMyFileStream);override; end; |
Ok zerknijmy szybko na to co naskrobaliśmy, mamy listę fSlaves, w której będziemy trzymali inne obiekty ponadto mamy dwie dodatkowe zmienne ,które są tylko po to aby zapisać coś jeszcze poza innymi obiektami no i przeciążamy funkcje zapisu/odczytu, teraz czas na bebechy:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 |
constructor TMaster.Create; var t: integer; begin fInteger := Random(100); fSlaves := TList.Create; for t := 0 to 10 do TSlave.Create(t); for t:= 0 to 30 do fAReal[t] := t*t; end; constructor TMaster.Create(const a: TMyFileStream); begin fSlaves := TList.Create; LoadState(a); end; destructor TMaster.destroy; begin while fSlaves.Count >0 do TSlave(fSlaves[0]).Free; fSlaves.Free; end; procedure TMaster.SaveState(const a:TMyFileStream);override; var data: TMasterSaveBlock; t: integer; begin FillChar( data, SizeOf(data), 0 ); //UWAGA: Wazne nie pomijac data.fInteger := fInteger; data.fAReal := fAReal; data.fSlaveCount := fSlaves.Count; a.Write( data, SizeOf(data) ); //zapis niewolnikow for t := 0 to fSlaves.Count -1 do TSlave( fSlaves[t] ).SaveState(a); end; procedure TMaster.LoadState(const a:TMyFileStream);override; var data: TMasterSaveBlock; t: integer; begin FillChar( data, SizeOf(data), 0 ); //UWAGA: Wazne nie pomijac a.Read( data, SizeOf(data) ); fInteger := data.fInteger; fAReal := data.fAReal; for t := 0 to data.fSlaveCount -1 do TSlave( fSlaves[t] ).Create(a); end; |
Dobra małe tłumaczonko, mamy 2 dodatkowe typy, o których jeszcze nie wspomniałem TRealArray i TMasterSaveBlock pierwszy jest to tablica na jakieś głupoty jest tylko po to aby pokazać jak łatwo i bezstresowo można przechowywać różne typy danych. Drugi typ jest rekordem, w którym przechowujemy wszystkie dane, które obiekt TMaster będzie chciał zapisać. Jak widać mamy dwie funkcje Create, pierwsza jest do tworzenia obiektów gdy np. zaczynamy grę lub gdy po prostu musimy dynamicznie stworzyć obiekt, druga wersja konstruktora tworzy obiekt na podstawie danych otrzymanych ze streamu (najczęściej z pliku). Teraz zerknijmy na TSlave.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 |
type TSlaveSaveBlock = record fName: ShortString; fDynamicCount: Integer; end; TSlave = class(TSavableObject) public constructor Create(Const Master:TMaster;const nr:integer); overload; constructor Create(Const Master:TMaster;const a: TMyFileStream);overload; destructor Destroy;override; private fName: String; fArray: array of integer; fMaster: TMaster; protected procedure SaveState(const a:TMyFileStream);override; procedure LoadState(const a:TMyFileStream);override; end; constructor TSlave.Create(Const Master:TMaster; const nr:integer); var t: integer; begin fName := Format('Slave nr %d',[nr]); SetLength( fArray, Random(100) ); for t := 0 to High(fArray) do fArray[t] := t; Master.fSlaves.Add(self); fMaster := Master; end; constructor TSlave.Create(Const Master:TMaster;const a: TMyFileStream); begin Master.fSlaves.Add(self); fMaster := Master; LoadState(a); end; destructor TSlave.destroy; begin fArray := nil; fMaster.fSlaves.Remove(self); end; procedure TSlave.SaveState(const a:TMyFileStream);override; var data: TSlaveSaveBlock; t: integer; begin FillChar( data, SizeOf(data), 0 ); //UWAGA: Wazne nie pomijac data.fName := fName; //uwaga na dlugosc ciagu ! data.fDynamicCount := Length( fArray ); a.Write( data, SizeOf(data) ); for t := 0 to data.fDynamicCount -1 do a.Write( data[t], SizeOf(Integer) ); end; procedure TSlave.LoadState(const a:TMyFileStream);override; var data: TMasterSaveBlock; t: integer; begin FillChar( data, SizeOf(data), 0 ); //UWAGA: Wazne nie pomijac a.Read( data, SizeOf(data) ); fName := data.fName; SetLength( fArray, data.fDynamicCount); for t := 0 to data.fDynamicCount -1 do a.Read( data[t], SizeOf(Integer) ); end; |
Kod w miare prosty nie powinno być problemów ze zrozumieniem. Na koniec kilka przemyśleń, do którch doszedłem podczas pisania swojego save:
1. Wszystkie obiekty, które coś chcą zapisać powinny mieć swój rekord, w którym będą trzymały dane i zapisywały go pojedynczym TStream.Write. należy unikać takich sytuacji:
1 2 |
a.Write( fInteger, SizeOf(Integer); a.Write( fReal, SizeOf(real); |
Zamiast tego pakujemy wszystko do rekordu i zapisujemy za jednym zamachem. Może wydawać się, że kod jest będzie miał takie same działanie jednak nie dajcie się zwieść pozorą czasami bardzo smutne sprawy z tego wynikaja ja straciłem na tym koło 3 h.
2. Warto w rekordach typy TXXXSaveBlock dorzucić kilka nieużywanych pól np:
1 2 3 4 5 6 |
TSlaveSaveBlock = record fName: ShortString; fDynamicCount: Integer; fUnused1: integer; fUnused2: Real; end; |
o co ? Ano czasami zdarza się że macie np definicje jakiegoś przedmiotu, no i wczytujecie 200 przedmiotów z pliku. Po dwóch tygodniach okazuje się że potrzebujecie jeszcze jeden parametr opisujący wszystkie przedmioty no i tu jest problem, albo robicie konwerter z jednego formatu na nowy albo wykorzystujecie pole Unused i wasze stare pliki ładnie się wczytają a po zmianie pola fUnused1 na np fWaga, wszystko ładnie się zapisze. Prosto i elegancko.
3. Nigdy nie zapominajcie o FillChar( data, SizeOf(data), 0 ); bo jak wszyscy wiemy pola są alignowane i w przerwach często siedzi smiecie dlatego warto to wywalić za wczasu, a nie później siedzieć po nocach i szukać czemy sie checkSumy nie zgadzają….
3. W TSlave.SaveBlock pokazałem jak moża zapisywać dynamicznie tworzone tablice nie polecam jednak tego sposbu napisałem go aby pokazać, że też się da, a czasami nawet trzeba…
4. Procka na checksum jest prosta jak drut ale w wielu przypadkach zdaje egzamin jak się komuś nie podoba zawsze może ją przerobić na CRC.
I to tyle, mam nadzieje, że się komuś przyda. Jak ktoś uważa że powinienem temat rozwinąć niech do mnie napisze po otrzymaniu 1e4 maili postaram się stworzyć coś bardziej wyczerpującego ;}
Autor: Toster