Spielstände speichern?



  • Ich wüsste gerne, wie so ein Format für ein gespeichertes Spiel aussehen könnte.

    Habt ihr mal einen Link zu einer Spezifikation/Beschreibung von so einem Format?

    Das Spiel ist egal.



  • In der Regel Werte, von irgendwelchen Seperatoren getrennt.
    Da gibts eigentlich nichts großes zu zeigen.



  • ne.

    ich rede nicht von config-files, sondern von richtigen savegame.

    z.b.:
    http://www.uesp.net/wiki/Tes5Mod:Save_File_Format

    ich sehe da nirgends irgendeinen separator 🙄



  • Interessant.
    Du redest nicht von Configfiles schickst mir aber einen Link, wo ein Savegameformat beschrieben ist, was wie ein simples Configfile aufgebaut ist. Aha.



  • Nathan schrieb:

    Interessant.
    Du redest nicht von Configfiles schickst mir aber einen Link, wo ein Savegameformat beschrieben ist, was wie ein simples Configfile aufgebaut ist. Aha.

    nö. das format ist binär. da ist nix mit separatoren.



  • Ach so, dann habe ich das missverstanden.
    Weil da steht name und da dachte ich halt, das sähe wie so ein INI File aus.
    Mein Fehler.
    Ja klar, binär sind da keine Seperatoren, da gehts nur nach Größe.



  • Für ein komplexeres Spiel habe ich zuletzt ein Format verwendet, das in mehrere Blöcke aufgeteilt war. Ein Block bestand aus ID, Version, Länge und (Länge) Bytes an Daten. Durch die ID wurde ermittelt, zu welchem Objekt diese Daten gehören und die Daten dann von diesem Objekt interpretiert. Das waren dann zum Teil einfach sequentiell aneinandergereihte Ints und Floats.
    Ein Block beinhaltet auch so eine string/string-map.

    otze schrieb:

    Das führt aber nur zu a) langen Speicherzeiten und b) großen Savegames. Dein Argument lässt sich auch einfach dadurch entkräften, dass du am Anfang des Savegames einfach eine Versionsnummer einbaust. Wenn ein veralteter Speicherstand gelesen wird, kann der dann einfach in das neue Format umgewandelt werden. In deinem Szenario "was ist, wenn sich das Spiel ändert" muss man so etwas eh berücksichtigen. Ist ja in deinem Fall nicht anders: "was ist, wenn sich die Keys ändern"?

    Man kann sowas auch grundsätzlich abfangen, indem man zum Beispiel immer im größtmöglichen Datentyp speichert.

    Ist natürlich auch alles abhängig vom Spiel. In meinem Fall waren das gerademal ein paar Dutzend keys, die aber von Level zu Level unterschiedlich sein können. Durch die Map brauche ich z.B. auch keine Daten mitzuspeichern, die für den aktuellen Level gar nicht relevant sind.
    Daß sich keys ändern ist auch kein Problem. Neue Keys können problemlos hinzugefügt werden, alte werden ggf konvertiert oder einfach ignoriert.

    Sollte es dabei irgendwann um einige tausend Keys gehen (was vielleicht bei einem größeren RPG oder Adventure realistisch ist) wäre eine enum/int map immernoch eine Ausweichmöglichkeit. Wobei ich gerade da die Möglichkeit, über die Strings auch einfach aus einem Skript heraus zugreifen zu können, doch sehr schätzen würde.

    Bei den Structs ist man doch recht eingeschränkt. Neue Variablen hinzufügen geht nur am Ende. Variablen herauslöschen geht auch nicht. Falls doch, funktioniert das ganze einfache Laden über eine Adresse und Länge schon nicht mehr, und dann könnte man auch gleich eine Vernünftige Lösung zum speichern implementieren.


  • Mod

    Hangaman schrieb:

    ...wenn man ein Spiel auch für die sch*** Konsolenwelt mit ihrem knappen Speicher entwickeln möchte.
    Konsolen halten also mal wieder den Fortschritt auf, wie immer. ...

    man speichert die farbe nicht weil der speicher knapp ist? lol, wegen dir hab ich mich an meiner morgentlichen coke verschluckt 😃
    GTA1/2 haben auch nicht die ganze welt gespeichert, obwohl es moeglich gewesen waere 😉

    bau auch ein simples hash ein, z.B. CRC32 am ende ein, passiert schonmal dass was korrupt ist und du willst nicht ein savegame debuggen was an sich schon kaputt war.

    Ehrlich?
    Hashes du wirklich jedes Objekt?

    falls du pro objekt ein savegame machst 😛
    natuerlich nur ein CRC pro _ganzem_ save game 🙂


  • Mod

    rapso schrieb:

    entsprechend kannst du den logik state sauber in einem datenblock verwalten, z.b.

    struct GameState
    {
    ...
    uint8_t KilledEndBoss;
    ...
    };
    

    und diesen einfach z.b. per

    fwrite(&myGameState,1,sizeof(GameState),...);
    

    rausschreiben.

    Eben sowas sollte man nicht tun.

    er fragte nicht wie es in deiner welt zu tun waere, sondern mehrmals

    Wie machen das andere Spiele?

    und das war lediglich die antwort drauf, die mehrheit der spiele die ich kenne macht es so.

    Man bekommt hier schon Probleme, wenn man zu der Struct eine Variable hinzufügt (das Spiel versucht plötzlich mehr Daten aus dem File zu lesen, als dort hinein gespeichert wurden. Dabei gehören die folgenden Daten bereits zu einem anderen Objekt.)

    man muss sich ja nicht extra dumm anstellen. version konvertierungen gehoeren immer dazu.

    Oder man stellt im Nachhinein fest, man braucht nun ein int64 statt eines int32 für XP oder Geld...

    ja, oder wenn du statt watt jetzt volt und ampere seperat speichern moechtest -> versions konvertierung.

    Ich verwende für sowas lieber eine std::map<std::string, std::string>.
    Man kriegt alle primitiven Typen in einem String unter, ist flexibler, wenn man etwas hinzufügen / ändern muss (man will ja auch nicht während der Entwicklung alle Nase lang einen Savegame-Konvertor schreiben, weil sonst alle Savegames flöten gehen).

    waere mir viel zu ineffizient, am ende fragt sich ein spieler nicht wie es implementiert ist, sondern "warum muss ich wegen einem scheiss save game 5min warten?" (und sag mir jetzt nicht dass wegen einer std::map<string,string> die welt nicht langsam wird, es geht um das allgemeine denken bei sowas. manche spiele speichern save games waehrend du spielst ohne dass du es ueberhaupt merkst weil es bruchteile einer sekunde sind, andere lassen dich fuer das eine MB mehrere sekunden in einem menue haengen, davon spreche ich).

    und deine loesung deckt nur das wirklich einfachste und primitivste ab, viel haeufiger ist es dass man neue klassen/typen hat, logik aendert die daten anders interpretiert. sich dann soviel overhead aufzuhalsen weil du einen typen aenderst?
    ich nutze viel lieber eine kaskade von versionen ala

    Foo bar_version_002=bar_version_001;
    

    und der ctor regelt jegliche konvertierung, nicht nur simple primitive typen.

    Der Nachteil dabei ist ein etwas höherer Speicher-Bedarf durch die Strings, den finde ich allerdings heutzutage auch vernachlässigbar. Sollte der Speicherbedarf dennoch eine Rolle spielen, kann man statt des string-Keys auch einen enum verwenden.

    enum GameVariable
        XP,
        Gold,
        KilledBoss
    };
    
    std::map<GameVariable, int32_t> game_variables;
    

    und jetzt kommt "score" hinzu, summe aus XP+Gold, schon musst du logik zum laden und konvertieren verbauen, die std::map hilft hier 0.

    wenn dir deine variante reicht, mach es so, aber sag nicht allgemein gueltig "so sollte man es, so nicht". es gibt spiele die sich auch einfach nur den user input merken und das spiel nochmal abspielen (also nur die logik), weil sich soviel in einem spiel aendert, dass keine unserer varianten im entferntesten funktionieren wuerde. wenn du z.b. ein RPG hast in dem du durch eine riesen welt und 100 quests hast mit einem quest zu anfang dass dir eine scheinbar unbrauchbare goldene nasenklammer gibt, damit du in quest 101 den stinkigen dungeon betreten kannst, das aber erst vor 3 tagen eingebaut wurde, du jedoch ein save game von vor 5tagen debuggen willst, dann musst du ein mechanismuss haben dass dir garantiert dass das savegame nach quest 100 die nasenklammer in deinem inventar hat. da kannst du auch nicht pauschal sagen "jetzt ist die nasenklammer da", denn ein spieler koennte quest 7 uebersprungen haben, das ist oft bei RPGs.


  • Mod

    threadstarter schrieb:

    Ich wüsste gerne, wie so ein Format für ein gespeichertes Spiel aussehen könnte.

    Habt ihr mal einen Link zu einer Spezifikation/Beschreibung von so einem Format?

    Das Spiel ist egal.

    schau dir doch den doom3 quellcode an, oder quake, gibt doch genug spiele die open source gemacht wurden. dann siehst du nicht nur das format (was echt nur die halbe arbeit ist), sondern wie man es laedt und mit versionskonflikten umgeht.


  • Mod

    Baldur schrieb:

    Daß sich keys ändern ist auch kein Problem. Neue Keys können problemlos hinzugefügt werden, alte werden ggf konvertiert oder einfach ignoriert.

    statt also daten direkt zu konvertieren, machst du erstmal map magic und am ende konvertierst du doch. das klingt irgendwie redundant.

    Sollte es dabei irgendwann um einige tausend Keys gehen (was vielleicht bei einem größeren RPG oder Adventure realistisch ist) wäre eine enum/int map immernoch eine Ausweichmöglichkeit. Wobei ich gerade da die Möglichkeit, über die Strings auch einfach aus einem Skript heraus zugreifen zu können, doch sehr schätzen würde.

    wuerdest du einen extra pfad machen mit extra lade scripts oder meinst du im allgemeinen spielfluss alle daten immer auf map<string,string> spiegeln?
    beides klingt irgendwie scarry.

    Bei den Structs ist man doch recht eingeschränkt. Neue Variablen hinzufügen geht nur am Ende. Variablen herauslöschen geht auch nicht. Falls doch, funktioniert das ganze einfache Laden über eine Adresse und Länge schon nicht mehr, und dann könnte man auch gleich eine Vernünftige Lösung zum speichern implementieren.

    du konvertierst structs genau so wie es in deinem fall waere. du hast quasi dasselbe nur in komplex und langsam geschaffen. wenn eine variabel einen neuen typen hat z.b. float statt int, dann macht man das oft um den wertebereich zu aendern z.b. auf meter zu gehen statt cm, dann musst du also trotz map den typen umwandeln und seperat behandeln. wenn eine neue variabel eingefuegt wird oder reihenfolgen veraendert werden, ist das auch nicht pauschal mit "initialisieren wir immer auf 0", es kann sein dass der wert errechnet wird, es kann sein dass version5 den wert auf 3.5 setzt und veresion6 anhand dieser neuen variabel eine ganz andere bestimmt und version7 den wert auf 2.5 setzt. schon hast du eine konvertierungsabhaengig von der reihenfolge der initialisierung der variablen. da ist eine kaskade von save game konvertierungen sehr viel flexibler, sicherer, besonders bei grossen teams und langer entwicklungszeit wo du solche fehler sonst erlebst, weil niemand ueberblick wie der ganze code sich gegenseitig ueber die zeit von selbst 2wochen veraendert hat, aber 2wochen lange save games will die QA dennoch laden koennen um einen fix zu verifizieren.



  • Wow, mal nicht so schnell 🙂

    Ich hab ja oben geschrieben, daß mein Savegame aus mehreren Datenblöcken besteht und jedes Objekt seinen eigenen Block hat, bzw. selbst dafür verantwortlich ist sich selbst zu speichern und zu laden. Wäre also auch Team-kompatibel.

    Bei der Map ging es wirklich nur um irgendwelche Gamestates, Questflags, etc. und bei dem Skript bezog ich mich auch nur auf Skripte, die Questmechaniken, etc steuern. Da ists dann auch einfach ein "getVar" und "setVar" für die Skripte zu implementieren als jede Variable einzeln rüber zu hieven.

    An der Map selbst musste ich noch garnichts konvertieren, da hinzufügen von Keys ja eh ohne Probleme geht. Konvertierungen gibts dafür in den Objekt-Daten, die dort von den Objekten selbst behandelt werden.

    Das Ganze läuft übrigens auf Android und das Autosave ist fast nicht spürbar im Spiel 🙂

    Wie läuft denn die Konvertierung wenn du structs verwendest?

    struct SaveGame_V1 {
       uint32_t hp;
       uint32_t xp;
    };
    
    struct Savegame_V2 {
       uint32_t hp;
       uint64_t xp;
    }
    
    switch(version) {
       case 1: {
          SaveGame_V1 sg1;
          SaveGame_V2 sg2;
          read(&sg1);
          sg2.hp = sg1.hp;
          sg2.xp = sg1.xp;
          ...
          break;
       }
    }
    

    Wird doch auch schnell unübersichtlich und fehleranfällig.

    Da doch eher gleich auf das Memory-Dump verzichten und die Variablen einzeln lesen.

    SaveGame sg;
    
    sg.hp = readUInt32();
    
    switch(version) {
       case 1:
          sg.xp = readUInt32();
          break;
       case 2:
          sg.xp = readUInt64();
          break;
    }
    

    (ich weise darauf hin daß der Code als Pseudocode zu lesen ist)


  • Mod

    Baldur schrieb:

    Ich hab ja oben geschrieben, daß mein Savegame aus mehreren Datenblöcken besteht und jedes Objekt seinen eigenen Block hat, bzw. selbst dafür verantwortlich ist sich selbst zu speichern und zu laden. Wäre also auch Team-kompatibel.

    trennt dein team wer welche objekte bearbeiten darf? bei uns arbeiten wir alle zusammen am code, von daher kann es sein, dass alles von jedem modifiziert wird.

    Bei der Map ging es wirklich nur um irgendwelche Gamestates, Questflags, etc. und bei dem Skript bezog ich mich auch nur auf Skripte, die Questmechaniken, etc steuern. Da ists dann auch einfach ein "getVar" und "setVar" für die Skripte zu implementieren als jede Variable einzeln rüber zu hieven.

    damit emulierst du quasi ein property system?
    (bin mir noch nicht ganz sicher was du mit scripten und der map machst und wann)

    An der Map selbst musste ich noch garnichts konvertieren, da hinzufügen von Keys ja eh ohne Probleme geht. Konvertierungen gibts dafür in den Objekt-Daten, die dort von den Objekten selbst behandelt werden.

    das heisst, dein objekt waechst und waechst damit es ueber versionen hinweg konvertieren kann? du hast dann eine map, die string,string ist und zudem code der die strings dann noch modifiziert bzw die daraus extrahierten daten?

    Das Ganze läuft übrigens auf Android und das Autosave ist fast nicht spürbar im Spiel 🙂

    ach, das verstehst du unter 'groesseres projekt' 😃

    Wie läuft denn die Konvertierung wenn du structs verwendest?

    struct SaveGame_V1 {
       uint32_t hp;
       uint32_t xp;
    };
    
    struct Savegame_V2 {
       uint32_t hp;
       uint64_t xp;
    }
    
    switch(version) {
       case 1: {
          SaveGame_V1 sg1;
          SaveGame_V2 sg2;
          read(&sg1);
          sg2.hp = sg1.hp;
          sg2.xp = sg1.xp;
          ...
          break;
       }
    }
    

    peuse code kann z.B. sein:

    read(version)
    read(block,size)//block -> raw daten
    void* pCurrent=block;
    void* pNext=tempblock;
    
    switch(version)
    case 1: (SVersion2*)pNext->InitFrom((SVersion1*)pCurrent);std::swap(pNext,pCurrent);
    case 2: (SVersion3*)pNext->InitFrom((SVersion2*)pCurrent);std::swap(pNext,pCurrent);
    case 3: (SVersion4*)pNext->InitFrom((SVersion3*)pCurrent);std::swap(pNext,pCurrent);
    case 4: (SVersion5*)pNext->InitFrom((SVersion4*)pCurrent);std::swap(pNext,pCurrent);
    ...
    }
    return pCurrent;
    

    Wird doch auch schnell unübersichtlich und fehleranfällig.

    sieht mir uebersichtlich aus, einfach zu maintainen und sehr sicher (bezogen auf stabilitaet), wie konvertierst du von version1 bis version 100 wenn sich wertebereich, datentypen, layout etc. aendert?

    Da doch eher gleich auf das Memory-Dump verzichten und die Variablen einzeln lesen.

    SaveGame sg;
    
    sg.hp = readUInt32();
    
    switch(version) {
       case 1:
          sg.xp = readUInt32();
          break;
       case 2:
          sg.xp = readUInt64();
          break;
    }
    

    (ich weise darauf hin daß der Code als Pseudocode zu lesen ist)

    damit behandelst du isoliert den einfachen fall dass sich ein datentyp aendert. was ist mit dem beispiel wo 5 leute an diesem code arbeiten und eben float sg.xp hinzugefuegt wird und falls der wert nicht vorhanden ist, 2.5 engenommen wird. dann berechnet man in der naechsten version, dass "mana" in etwa sg.xp * 6.7 ist und in der version die danach kommt, hat jemand sg.xp mit 1.6 skaliert. wenn du also version 0 laedst, musst du

    case version0:
      sg.xp = 2.5f
      sg.mana = 16.75f
    break;
    case version1:
      sg.xp = readFloat()
      sg.mana = sg.xp*6.7f
    break;
    case version2:
      sg.xp = readFloat()*1.6f
      sg.mana = sg.xp*6.7f/1.6f
    break;
    case version3:
      sg.xp = readFloat()
      sg.mana = readFloat()
    break;
    

    das schaut mir fuer die kleine abhaengigkeit schon leicht unuebersichtlich aus, musst du dann pro version die du hinzufuegst, alle versionen die vorher potentiel geladen werden im switch case anpassen? steigert das dann dein maintainance mit O(VersionCount^2) ?


Anmelden zum Antworten