Comment puis-je conserver la compatibilité descendante des jeux enregistrés?

8

J'ai un jeu de simulation complexe auquel je veux ajouter des fonctionnalités de sauvegarde de jeu. Je le mettrai à jour avec de nouvelles fonctionnalités en permanence après sa sortie.

Comment puis-je m'assurer que mes mises à jour ne cassent pas les sauvegardes existantes? Quelle architecture dois-je suivre pour rendre cela possible?

pain de seigle
la source
Je ne suis pas au courant d'une architecture générique pour cet objectif, mais je ferais en sorte que le processus de correction met également à jour / convertisse les jeux de sauvegarde pour assurer la compatibilité avec les nouvelles fonctionnalités.
loodakrawa

Réponses:

9

Une approche simple consiste à conserver les anciennes fonctions de chargement. Vous n'avez besoin que d'une seule fonction de sauvegarde qui n'écrit que la dernière version. La fonction de chargement détecte la fonction de chargement versionnée correcte à invoquer (généralement en écrivant un numéro de version quelque part au début de votre format de fichier de sauvegarde). Quelque chose comme:

class GameState:
  loadV1(stream):
    // do stuff

  loadV2(stream):
    // do different stuff

  loadV3(stream):
    // yet other stuff

  save(stream):
    // note this is version 3
    stream.write(3)
    // write V3 data

  load(stream):
    version = stream.read()
    if version == 1: loadV1(stream)
    else if version == 2: loadV2(stream)
    else if version == 3: loadV3(stream)

Vous pouvez le faire pour le fichier entier, pour des sections individuelles du fichier, pour des objets / composants de jeu individuels, etc. La répartition qui sera la meilleure dépendra de votre jeu et de la quantité d'état que vous sérialisez.

Notez que cela ne vous mène que jusqu'à présent. À un moment donné, vous pouvez modifier votre jeu suffisamment pour que les données de sauvegarde des versions antérieures n'aient tout simplement aucun sens. Par exemple, un RPG peut avoir différentes classes de personnages que le joueur peut choisir. Si vous supprimez une classe de personnage, vous ne pouvez pas faire grand-chose avec les sauvegardes de personnages qui ont cette classe. Peut-être pourriez-vous le convertir en une classe similaire qui existe toujours ... peut-être. Il en va de même si vous modifiez suffisamment d'autres parties du jeu pour qu'il ne ressemble pas étroitement aux anciennes versions.

Sachez qu'une fois que vous avez expédié votre jeu, c'est «terminé». Vous pouvez publier des DLC ou d'autres mises à jour au fil du temps, mais ils n'apporteront pas de changements particulièrement importants au jeu lui-même. Prenez la plupart des MMO par exemple: WoW a été maintenu pendant de nombreuses années avec de nouvelles mises à jour et modifications, mais c'est toujours plus ou moins le même jeu que lors de sa sortie.

Pour un développement précoce, je ne m'en inquiéterais tout simplement pas. Les sauvegardes sont éphémères lors des premiers tests. C'est une autre histoire une fois que vous arrivez à la version bêta publique.

Sean Middleditch
la source
1
Cette. Malheureusement, cela fonctionne rarement aussi joli qu'annoncé. Habituellement, ces fonctions de chargement reposent sur des fonctions d'assistance ( ReadCharacterpeuvent appeler ReadStat, qui peuvent ou non changer d'une version à la suivante), vous devez donc conserver des versions pour chacune d'entre elles, ce qui rend la tâche de plus en plus difficile à suivre. Comme toujours, il n'y a pas de solution miracle et conserver les anciennes fonctions de chargement est un bon point de départ.
Panda Pyjama
5

Un moyen simple d'obtenir un semblant de version est de donner un sens aux membres des objets que vous sérialisez. Si votre code comprend les différents types de données à sérialiser, vous pouvez obtenir une certaine robustesse sans faire trop de travail.

Disons que nous avons un objet sérialisé qui ressemble à ceci:

ObjectType
{
  m_name = "a string"
  m_size = { 1.2, 2.1 }
  m_someStruct = {
    m_deeperInteger = 5
    m_radians = 3.14
  }
}

Il devrait être facile de voir que le type ObjectTypea des membres de données appelés m_name, m_sizeet m_someStruct. Si vous pouvez boucler ou énumérer des membres de données pendant l'exécution (d'une manière ou d'une autre), lors de la lecture de ce fichier, vous pouvez lire un nom de membre et le faire correspondre à un membre réel dans votre instance d'objet.

Au cours de cette phase de recherche, si vous ne trouvez pas de membre de données correspondant, vous pouvez ignorer en toute sécurité cette partie du fichier de sauvegarde. Par exemple, disons que la version 1.0 de SomeStructavait un m_namemembre de données. Ensuite, vous corrigez et ce membre de données a été entièrement supprimé. Lors du chargement de votre fichier de sauvegarde, vous rencontrerez m_nameun membre correspondant et vous ne trouverez aucune correspondance. Votre code peut simplement passer au membre suivant dans le fichier sans se bloquer. Cela vous permet de supprimer des membres de données sans vous soucier de casser les anciens fichiers de sauvegarde.

De même, si vous ajoutez un nouveau type de membre de données et essayez de charger à partir d'un ancien fichier de sauvegarde, votre code risque de ne pas initialiser le nouveau membre. Cela peut être utilisé à un avantage: de nouveaux membres de données peuvent être insérés dans les fichiers de sauvegarde pendant le patch manuellement, peut-être en introduisant des valeurs par défaut (ou par des moyens plus intelligents).

Ce format permet également aux fichiers de sauvegarde d'être facilement manipulés ou modifiés à la main; l'ordre dans lequel les membres des données n'ont pas vraiment grand-chose à voir avec la validité de la routine de sérialisation. Chaque membre est recherché et initialisé indépendamment. Cela pourrait être une subtilité qui ajoute un peu de robustesse supplémentaire.

Tout cela peut être réalisé par une certaine forme d'introspection de type. Vous voudrez pouvoir interroger un membre de données par recherche de chaîne et être en mesure de dire quel est le type réel de données du membre de données. Cela peut être réalisé en C ++ en utilisant une forme d'introspection personnalisée, et d'autres langages peuvent avoir des fonctionnalités d'introspection intégrées.

RandyGaul
la source
Cela sera utile pour rendre les données et les classes plus robustes. (Dans .NET, la fonction est appelée "réflexion"). Je me pose des questions sur les collections ... mon IA est compliquée et utilise de nombreuses collections temporaires pour traiter les données. Dois-je essayer d'éviter de les enregistrer ...? Limitez peut-être l'enregistrement aux «points de sécurité» où le traitement est terminé.
Pain de seigle
@aman Si vous enregistrez une collection, vous pouvez écrire les données réelles dans ces collections comme dans mon exemple d'origine, sauf dans un "format tableau", comme dans beaucoup d'entre elles d'affilée. Vous pouvez toujours appliquer la même idée à chaque élément individuel d'un tableau ou à tout autre conteneur. Il vous suffira d'écrire un "sérialiseur de tableau" générique, un "sérialiseur de liste", etc. Si vous voulez un "sérialiseur de conteneur" générique, vous aurez probablement besoin d'un résumé SerializingIteratorquelconque, et cet itérateur sera implémenté pour chaque type de conteneur.
RandyGaul
1
Oh et oui, vous devriez essayer d'éviter autant que possible d'enregistrer des collections compliquées avec des pointeurs. Souvent, cela peut être évité avec beaucoup de réflexion et de conception intelligente. La sérialisation est quelque chose qui peut devenir très compliqué, il sera donc avantageux d'essayer de le simplifier autant que possible. @aman
RandyGaul
Il y a aussi le problème de désérialisation d'un objet lorsque la classe a changé ... Je pense que le désérialiseur .NET va planter dans de nombreux cas.
Pain de seigle
2

C'est un problème qui existe non seulement sur les jeux, mais aussi sur toute application d'échange de fichiers. Certes, il n'y a pas de solutions parfaites, et essayer de créer un format de fichier compatible avec tout type de changement sera probablement impossible, c'est donc probablement une bonne idée de se préparer au type de changements que vous attendez.

La plupart du temps, vous n'aurez probablement qu'à ajouter / supprimer des champs et des valeurs, tout en préservant la structure générale de vos fichiers. Dans ce cas, vous pouvez simplement écrire votre code pour ignorer les champs inconnus et utiliser des valeurs par défaut raisonnables lorsqu'une valeur ne peut pas être comprise / analysée. L'implémentation est assez simple, et je le fais beaucoup.

Cependant, vous souhaiterez parfois modifier la structure du fichier. Dites du texte au binaire; ou des champs fixes à la valeur de taille. Dans ce cas, vous souhaiterez très probablement geler la source de l'ancien lecteur de fichiers et en créer un nouveau pour le nouveau type de fichier, comme dans la solution de Sean. Assurez-vous d'isoler l'intégralité du lecteur hérité, sinon vous risquez de modifier quelque chose qui l'affecte. Je le recommande uniquement pour les changements majeurs de structure de fichiers.

Ces deux méthodes devraient fonctionner dans la plupart des cas, mais gardez à l'esprit qu'elles ne sont pas les seuls changements possibles que vous pourriez rencontrer. J'ai eu un cas dans lequel j'ai dû changer le code de chargement de niveau entier de la lecture entière au streaming (pour la version mobile du jeu, qui devrait fonctionner sur des appareils avec une bande passante et une mémoire considérablement réduites). Un changement comme celui-ci est beaucoup plus profond et nécessitera très probablement des changements dans de nombreuses autres parties du jeu, dont certaines nécessitaient des changements dans la structure du fichier lui-même.

Pyjama Panda
la source
0

À un niveau supérieur: si vous ajoutez de nouvelles fonctionnalités au jeu, ayez une fonction "Devinez de nouvelles valeurs" qui peut prendre les anciennes fonctionnalités et deviner quelles seront les nouvelles valeurs.

Un exemple pourrait rendre cela plus clair. Supposons qu'un jeu modélise les villes et que la version 1.0 suit le niveau global de développement des villes, tandis que la version 1.1 ajoute des bâtiments spécifiques de type civilisation. (Personnellement, je préfère suivre le développement global, comme étant moins irréaliste; mais je m'égare.) GuessNewValues ​​() pour 1.1, étant donné un fichier de sauvegarde 1.0, commencerait par une ancienne figure de niveau de développement, et devinez, sur cette base, ce que des bâtiments auraient été construits dans la ville - peut-être en regardant la culture de la ville, sa position géographique, l'axe de son développement, ce genre de chose.

J'espère que cela peut être compréhensible en général - que si vous ajoutez de nouvelles fonctionnalités à un jeu, le chargement d'un fichier de sauvegarde qui n'a pas encore ces fonctionnalités nécessite de faire de meilleures suppositions sur ce que seront les nouvelles données et de les combiner avec les données que vous avez chargées.

Pour le bas niveau des choses, je souscrirais à la réponse de Sean Middleditch (que j'ai votée): conserver la logique de charge existante, peut-être même conserver les anciennes versions des classes pertinentes, et appeler d'abord cela, puis un convertisseur.

ExOttoyuhr
la source
0

Je suggérerais d'utiliser quelque chose comme XML (si vous enregistrez des fichiers très petits) de cette façon, vous n'avez besoin que d'une fonction pour gérer le balisage, peu importe ce que vous y mettez. Le nœud racine de ce document pourrait déclarer la version qui a sauvé le jeu et vous permettre d'écrire du code pour mettre à jour le fichier vers la dernière version si nécessaire.

<save version="1">
  <player name="foo" score="10" />
  <data>![CDATA[lksdf9owelkjlkdfjdfgdfg]]</data>
</save>

Cela signifie également que vous pouvez appliquer une transformation si vous souhaitez convertir les données dans un "format de version actuel" avant de charger les données. Au lieu d'avoir de nombreuses fonctions versionnées, vous auriez simplement un ensemble de fichiers xsl parmi lesquels vous choisiriez pour faire la conversion. Cela peut prendre du temps si vous n'êtes pas familier avec xsl.

Si vos fichiers de sauvegarde sont massifs, le format xml pourrait être un problème, en général, j'ai des fichiers de sauvegarde qui fonctionnent très bien là où vous venez de vider des paires de valeurs clés dans le fichier comme ceci ...

version=1
player=foo
data=lksdf9owelkjlkdfjdfgdfg
score=10

Ensuite, lorsque vous lisez ce fichier, vous écrivez et lisez toujours une variable de la même manière, si vous avez besoin d'une nouvelle variable, vous créez une nouvelle fonction pour l'écrire et la lire. vous pouvez simplement écrire une fonction pour les types de variables afin d'avoir un "lecteur de chaîne" et un "lecteur int", cela ne serait valable que si vous changiez un type de variable entre les versions mais vous ne devriez jamais le faire car la variable signifie autre chose à ce point, vous devez donc créer une nouvelle variable avec un nom différent.

L'autre façon est bien sûr d'utiliser un format de type de base de données ou quelque chose comme un fichier csv, mais cela dépend des données que vous enregistrez.

Guerre
la source