Como posso manter a compatibilidade com versões anteriores de jogos salvos?

8

Eu tenho um jogo sim complexo ao qual desejo adicionar a funcionalidade de salvar jogo. Vou atualizá-lo com novos recursos continuamente após o lançamento.

Como garantir que minhas atualizações não quebrem os jogos salvos existentes? Que tipo de arquitetura devo seguir para tornar isso possível?

pão de centeio
fonte
Não conheço uma arquitetura genérica para esse objetivo, mas faria com que o processo de atualização também atualizasse / convertesse os jogos salvos para garantir a compatibilidade com os novos recursos.
precisa saber é o seguinte

Respostas:

9

Uma abordagem fácil é manter as antigas funções de carregamento. Você precisa apenas de uma única função de gravação que grave apenas a versão mais recente. A função de carregamento detecta a função de carregamento com versão correta a ser chamada (geralmente escrevendo um número de versão em algum lugar no início do seu formato de arquivo salvo). Algo como:

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)

Você pode fazer isso para o arquivo inteiro, para seções individuais do arquivo, para objetos / componentes de jogos individuais, etc. Exatamente qual a melhor divisão dependerá do seu jogo e da quantidade de estado que você está serializando.

Observe que isso só leva você até agora. Em algum momento, você poderá alterar seu jogo o suficiente para que os dados salvos das versões anteriores simplesmente não façam sentido. Por exemplo, um RPG pode ter diferentes classes de personagens que o jogador pode escolher. Se você remover uma classe de personagem, não há muito o que fazer com salvamentos de personagens dessa classe. Talvez você possa convertê-lo para uma classe semelhante que ainda existe ... talvez. O mesmo acontece se você alterar outras partes do jogo o suficiente para que não se pareça com as versões antigas.

Esteja ciente de que, depois de enviar seu jogo, ele estará "pronto". Você pode lançar o DLC ou outras atualizações ao longo do tempo, mas elas não serão mudanças particularmente grandes no próprio jogo. Veja a maioria dos MMOs, por exemplo: o WoW é mantido por muitos anos com novas atualizações e alterações, mas ainda é mais ou menos o mesmo jogo que era quando foi lançado.

Para o desenvolvimento inicial, simplesmente não me preocupava com isso. Os salvamentos são efêmeros nos primeiros testes. É outra história quando você chega à versão beta pública.

Sean Middleditch
fonte
11
Este. Infelizmente, isso raramente funciona tão bem quanto o anunciado. Normalmente, essas funções de carregamento dependem de funções auxiliares ( ReadCharacterpodem ser chamadas ReadStat, que podem ou não mudar de uma versão para a próxima), portanto, você precisará manter as versões de cada uma delas, dificultando cada vez mais o acompanhamento. Como sempre, não há nenhuma bala de prata, e manter as funções antigas de carregamento é um bom ponto de partida.
Panda Pyjama
5

Uma maneira simples de obter uma aparência de versionamento é entender os membros dos objetos que você está serializando. Se o seu código compreender os vários tipos de dados a serem serializados, você poderá obter uma certa robustez sem fazer muito trabalho.

Digamos que temos um objeto serializado que se parece com isso:

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

Deve ser fácil de ver que o tipo ObjectTypetem membros de dados chamadas m_name, m_sizee m_someStruct. Se você puder fazer um loop ou enumerar membros de dados durante o tempo de execução (de alguma forma), ao ler este arquivo, poderá ler um nome de membro e associá-lo a um membro real dentro da instância do objeto.

Durante essa fase de pesquisa, se você não encontrar um membro de dados correspondente, poderá ignorar com segurança essa parte do arquivo salvo. Por exemplo, digamos que a versão 1.0 de SomeStructtivesse um m_namemembro de dados. Em seguida, você faz o patch e esse membro de dados foi removido completamente. Ao carregar seu arquivo salvo, você encontrará m_namee procurará um membro correspondente e não encontrará correspondência. Seu código pode simplesmente passar para o próximo membro do arquivo sem travar. Isso permite remover membros de dados sem preocupações com a quebra de arquivos salvos antigos.

Da mesma forma, se você adicionar um novo tipo de membro de dados e tentar carregar de um antigo arquivo salvo, seu código poderá não inicializar o novo membro. Isso pode ser utilizado como vantagem: novos membros de dados podem ser inseridos nos arquivos salvos durante o patch manualmente, talvez introduzindo valores padrão (ou por meios mais inteligentes).

Esse formato também permite que os arquivos salvos sejam facilmente manipulados ou modificados manualmente; a ordem na qual os membros dos dados realmente não têm muito a ver com a validade da rotina de serialização. Cada membro é procurado e inicializado independentemente. Isso pode ser um detalhe que adiciona um pouco de robustez extra.

Tudo isso pode ser alcançado através de alguma forma de introspecção de tipo. Você poderá consultar um membro de dados por pesquisa de sequência e saber qual é o tipo real de dados. Isso pode ser alcançado em C ++ usando uma forma de introspecção personalizada, e outros idiomas podem ter recursos de introspecção incorporados.

RandyGaul
fonte
Isso será útil para tornar os dados e as classes mais robustos. (No .NET, o recurso é chamado de "reflexão"). Eu me pergunto sobre coleções ... minha IA é complicada e usa muitas coleções temporárias para processar dados. Devo tentar evitar salvá-los ...? Talvez limite a economia a "pontos seguros" onde o processamento terminou.
Pão de centeio
@ aman Se você salvar uma coleção, poderá escrever os dados reais nessas coleções, como no meu exemplo original, exceto em um "formato de matriz", como em muitos deles em uma linha. Você ainda pode aplicar a mesma idéia a cada elemento individual de uma matriz ou a qualquer outro contêiner. Você apenas precisará escrever algum "serializador de matriz", "serializador de lista" genérico etc. Se você quiser um "serializador de contêiner" genérico, provavelmente precisará de um resumoSerializingIterator de algum tipo, e esse iterador seria implementado para cada tipo de contêiner.
precisa saber é o seguinte
11
Ah, sim, você deve evitar salvar coleções complicadas com ponteiros o máximo possível. Muitas vezes, isso pode ser evitado com muito pensamento e design inteligente. Serialização é algo que pode ficar muito complicado, por isso vale a pena tentar simplificá-la o máximo possível. @aman
RandyGaul 16/01
Há também o problema de desserializar um objeto quando a classe muda ... Acho que o desserializador do .NET falhará em muitos casos.
Pão de centeio
2

Esse é um problema que existe não apenas nos jogos, mas também em qualquer aplicativo de troca de arquivos. Certamente, não há soluções perfeitas, e é provável que seja impossível tentar um formato de arquivo que seja compatível com qualquer tipo de alteração; portanto, é provavelmente uma boa idéia se preparar para o tipo de alteração que você pode estar esperando.

Na maioria das vezes, você provavelmente estará apenas adicionando / removendo campos e valores, mantendo a estrutura geral de seus arquivos intacta. Nesse caso, você pode simplesmente escrever seu código para ignorar campos desconhecidos e usar padrões sensíveis quando um valor não puder ser entendido / analisado. Implementar isso é bem direto, e eu faço muito isso.

No entanto, às vezes você deseja alterar a estrutura do arquivo. Diga de baseado em texto para binário; ou de campos fixos para tamanho-valor. Nesse caso, você provavelmente desejará congelar a fonte do leitor de arquivos antigo e criar um novo para o novo tipo de arquivo, como na solução de Sean. Certifique-se de isolar todo o leitor herdado, ou você pode acabar modificando algo que o afeta. Eu recomendo isso apenas para alterações importantes na estrutura do arquivo.

Esses dois métodos devem funcionar na maioria dos casos, mas lembre-se de que não são as únicas alterações possíveis que você pode encontrar. Tive um caso em que precisei alterar todo o código de carregamento de nível, de leitura completa para streaming (para a versão móvel do jogo, que deve funcionar em dispositivos com largura de banda e memória significativamente reduzidas). Uma mudança como essa é muito mais profunda e provavelmente exigirá alterações em muitas outras partes do jogo, algumas das quais exigiram alterações na estrutura do próprio arquivo.

Panda Pajama
fonte
0

Em um nível mais alto: se você estiver adicionando novos recursos ao jogo, tenha uma função "Adivinha novos valores", que pode usar os recursos antigos e adivinhar quais serão os valores dos novos.

Um exemplo pode deixar isso mais claro. Suponha que um jogo modele cidades, e que a versão 1.0 rastreie o nível geral de desenvolvimento das cidades, enquanto a versão 1.1 adiciona edifícios específicos do tipo Civilization. (Pessoalmente, prefiro acompanhar o desenvolvimento geral como menos irreal; mas discordo.) GuessNewValues ​​() para 1.1, com um arquivo de gravação 1.0, começaria com uma figura antiga de nível de desenvolvimento, e acho que, com base nisso, o que edifícios teriam sido construídos na cidade - talvez olhando para a cultura da cidade, sua posição geográfica, o foco de seu desenvolvimento, esse tipo de coisa.

Espero que isso seja compreensível em geral - que, se você estiver adicionando novos recursos a um jogo, carregar um arquivo de salvamento que ainda não possua esses recursos exige que você faça melhores palpites sobre quais serão os novos dados e que combine esses dados com os dados que você carregou.

Para o lado de baixo nível, eu endossaria a resposta de Sean Middleditch (que eu votei para cima): mantenha a lógica de carga existente, possivelmente até mantendo versões antigas das classes relevantes e chame primeiro isso, depois um conversor.

ExOttoyuhr
fonte
0

Eu sugeriria usar algo como XML (se você salvar arquivos muito pequenos) dessa forma, você só precisa de 1 função para lidar com a marcação, independentemente do que você colocar nela. O nó raiz desse documento pode declarar a versão que salvou o jogo e permitir que você escreva um código para atualizar o arquivo para a versão mais recente, se necessário.

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

Isso também significa que você pode aplicar uma transformação se desejar converter os dados em um "formato de versão atual" antes de carregá-los. Em vez de ter muitas funções com versão disponíveis, você simplesmente terá um conjunto de arquivos xsl à sua escolha para fazer a conversão. Isso pode ser demorado, se você não estiver familiarizado com o xsl.

Se os seus arquivos salvos são maciços xml, pode ser um problema, normalmente eu salvo os arquivos que funcionam muito bem, onde você apenas despeja pares de valores-chave no arquivo como este ...

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

Então, quando você lê esse arquivo, sempre escreve e lê uma variável da mesma maneira; se precisar de uma nova variável, cria uma nova função para escrevê-la e lê-la. você pode simplesmente escrever uma função para tipos de variáveis ​​para ter um "leitor de strings" e um "int reader", isso só seria útil se você alterasse um tipo de variável entre as versões, mas nunca deveria fazer isso porque a variável significa outra coisa em Nesse ponto, você deve criar uma nova variável com um nome diferente.

A outra maneira, claro, é usar um formato de tipo de banco de dados ou algo como um arquivo csv, mas depende dos dados que você está salvando.

Guerra
fonte