Você pode visualizar seu sistema como se ele fosse composto por uma série de estados e funções, onde uma função f[j]
com entrada x[j]
altera o estado do sistema s[j]
em estado s[j+1]
, da seguinte forma:
s[j+1] = f[j](s[j], x[j])
Um estado é a explicação de todo o seu mundo. A localização do jogador, a localização do inimigo, a pontuação, a munição restante, etc. Tudo o que você precisa para desenhar um quadro do seu jogo.
Uma função é qualquer coisa que possa afetar o mundo. Uma mudança de quadro, um pressionamento de tecla, um pacote de rede.
A entrada são os dados que a função utiliza. Uma mudança de quadro pode levar a quantidade de tempo desde que o último quadro passou, o pressionamento de tecla pode incluir a tecla real pressionada, bem como se a tecla shift foi pressionada ou não.
Para fins desta explicação, farei as seguintes suposições:
Suposição 1:
A quantidade de estados para uma determinada execução do jogo é muito maior que a quantidade de funções. Você provavelmente possui centenas de milhares de estados, mas apenas várias dezenas de funções (mudança de quadro, pressionamento de tecla, pacote de rede etc.). Obviamente, a quantidade de entradas deve ser igual à quantidade de estados menos um.
Suposição 2:
O custo espacial (memória, disco) de armazenar um único estado é muito maior que o de armazenar uma função e sua entrada.
Suposição 3:
O custo temporal (tempo) de apresentar um estado é semelhante, ou apenas uma ou duas ordens de magnitude mais longas que o cálculo de uma função sobre um estado.
Dependendo dos requisitos do seu sistema de reprodução, existem várias maneiras de implementar um sistema de reprodução, para que possamos começar pelo mais simples. Também farei um pequeno exemplo usando o jogo de xadrez, gravado em pedaços de papel.
Método 1:
Loja s[0]...s[n]
. Isso é muito simples, muito direto. Por causa da suposição 2, o custo espacial disso é bastante alto.
Para o xadrez, isso seria realizado ao desenhar o tabuleiro inteiro para cada jogada.
Método 2:
Se você precisar apenas executar a reprodução direta, poderá simplesmente armazenar s[0]
e depois armazenar f[0]...f[n-1]
(lembre-se, este é apenas o nome do ID da função) e x[0]...x[n-1]
(qual foi a entrada para cada uma dessas funções). Para reproduzir, basta começar s[0]
e calcular
s[1] = f[0](s[0], x[0])
s[2] = f[1](s[1], x[1])
e assim por diante...
Quero fazer uma pequena anotação aqui. Vários outros comentaristas disseram que o jogo "deve ser determinístico". Qualquer um que disser que precisa fazer o Computer Science 101 novamente, porque, a menos que seu jogo seja executado em computadores quânticos, TODOS OS PROGRAMAS DO COMPUTADOR SÃO DETERMINÍSTICOS¹. É isso que torna os computadores tão incríveis.
No entanto, como seu programa provavelmente depende de programas externos, variando de bibliotecas até a implementação real da CPU, certifique-se de que suas funções se comportem da mesma maneira entre as plataformas.
Se você usar números pseudo-aleatórios, poderá armazenar os números gerados como parte de sua entrada x
ou o estado da função prng como parte do seu estado s
e sua implementação como parte da função f
.
Para o xadrez, isso seria realizado desenhando o quadro inicial (que é conhecido) e depois descrevendo cada jogada dizendo qual peça foi para onde. É assim que eles realmente fazem, a propósito.
Método 3:
Agora, é provável que você queira pesquisar sua reprodução. Ou seja, calcule s[n]
para um arbitrário n
. Usando o método 2, você precisa calcular s[0]...s[n-1]
antes de poder calculars[n]
, o que, de acordo com a suposição 2, pode ser bastante lento.
Para implementar isso, o método 3 é uma generalização dos métodos 1 e 2: armazene f[0]...f[n-1]
e, x[0]...x[n-1]
assim como o método 2, mas também armazene s[j]
, para todos, j % Q == 0
para uma determinada constante Q
. Em termos mais fáceis, isso significa que você armazena um marcador em um de cada Q
estado. Por exemplo, para Q == 100
, você armazenas[0], s[100], s[200]...
Para calcular s[n]
para um arbitrário n
, primeiro você carrega o armazenado anteriormente s[floor(n/Q)]
e depois calcula todas as funções de floor(n/Q)
até n
. No máximo, você calculará Q
funções. Valores menores de Q
são mais rápidos de calcular, mas consomem muito mais espaço, enquanto valores maiores deQ
consomem menos espaço, mas levam mais tempo para serem calculados.
O método 3 com Q==1
é o mesmo que o método 1, enquanto o método 3 comQ==inf
é o mesmo que o método 2.
Para o xadrez, isso seria realizado ao desenhar cada movimento e um em cada 10 tabuleiros (para Q==10
).
Método 4:
Se você quiser reverter replay, você pode fazer uma pequena variação do método 3. Suponha Q==100
, e você deseja calcular s[150]
através s[90]
no sentido inverso. Com o método não modificado 3, você precisará fazer 50 cálculos para obter s[150]
e mais 49 cálculos para obter s[149]
e assim por diante. Mas como você já calculou o s[149]
recebimento s[150]
, é possível criar um cache s[100]...s[150]
quando calcular s[150]
pela primeira vez, e então você jás[149]
no cache quando precisar exibi-lo.
Você só precisa regenerar o cache toda vez que precisar calcular s[j]
, j==(k*Q)-1
para qualquer dado k
. Dessa vez, aumentar Q
resultará em tamanho menor (apenas para o cache), mas em períodos mais longos (apenas para recriar o cache). Um valor ideal paraQ
pode ser calculado se você souber os tamanhos e tempos necessários para calcular estados e funções.
Para o xadrez, isso seria realizado desenhando todos os movimentos, assim como um em cada 10 tabuleiros (para Q==10
), mas também seria necessário desenhar em um pedaço de papel separado, os últimos 10 tabuleiros que você calculou.
Método 5:
Se os estados simplesmente consomem muito espaço ou as funções consomem muito tempo, você pode criar uma solução que realmente implemente (e não falsifique) a reprodução reversa. Para fazer isso, você deve criar funções reversas para cada uma das funções que possui. No entanto, isso requer que cada uma de suas funções seja uma injeção. Se isso for possível, para f'
denotar o inverso da função f
, o cálculo s[j-1]
é tão simples quanto
s[j-1] = f'[j-1](s[j], x[j-1])
Observe que aqui, a função e a entrada são ambas j-1
, não j
. Essa mesma função e entrada seriam as que você usaria se estivesse calculando
s[j] = f[j-1](s[j-1], x[j-1])
Criar o inverso dessas funções é a parte complicada. No entanto, você geralmente não pode, pois alguns dados de estado geralmente são perdidos após cada função em um jogo.
Esse método, como está, pode reverter o cálculo s[j-1]
, mas somente se você tiver s[j]
. Isso significa que você só pode assistir a reprodução ao contrário, a partir do ponto em que decidiu reproduzir ao contrário. Se você deseja reproduzir novamente de um ponto arbitrário, misture isso com o método 4.
No xadrez, isso não pode ser implementado, pois, com um determinado tabuleiro e o movimento anterior, você pode saber qual peça foi movida, mas não para onde foi movida.
Método 6:
Finalmente, se você não pode garantir que todas as suas funções sejam injeções, pode fazer um pequeno truque para fazer isso. Em vez de cada função retornar apenas um novo estado, você também pode retornar os dados descartados, assim:
s[j+1], r[j] = f[j](s[j], x[j])
Onde r[j]
estão os dados descartados. E então crie suas funções inversas para que eles tomem os dados descartados, assim:
s[j] = f'[j](s[j+1], x[j], r[j])
Além de f[j]
e x[j]
, você também deve armazenar r[j]
para cada função. Mais uma vez, se você deseja procurar, deve armazenar marcadores, como no método 4.
No xadrez, isso seria o mesmo que o método 2, mas, diferentemente do método 2, que diz apenas qual peça vai para onde, você também precisa armazenar de onde veio cada peça.
Implementação:
Como isso funciona para todos os tipos de estados, com todos os tipos de funções, para um jogo específico, você pode fazer várias suposições, que facilitarão a implementação. Na verdade, se você implementar o método 6 com todo o estado do jogo, não apenas poderá reproduzir os dados, mas também voltar no tempo e retomar a reprodução a qualquer momento. Isso seria incrível.
Em vez de armazenar todo o estado do jogo, você pode simplesmente armazenar o mínimo necessário para desenhar um determinado estado e serializar esses dados a cada período fixo de tempo. Seus estados serão essas serializações e sua entrada será agora a diferença entre duas serializações. A chave para que isso funcione é que a serialização deve mudar pouco se o estado mundial mudar pouco também. Essa diferença é completamente reversível, portanto, a implementação do método 5 com indicadores é muito possível.
Eu já vi isso implementado em alguns jogos importantes, principalmente para reprodução instantânea de dados recentes quando ocorre um evento (um frag em fps ou uma pontuação em jogos esportivos).
Espero que essa explicação não tenha sido muito chata.
¹ Isso não significa que alguns programas agem como se fossem não determinísticos (como o MS Windows ^^). Agora, falando sério, se você pode criar um programa não determinístico em um computador determinístico, pode ter certeza de que ganhará simultaneamente a medalha Fields, o prêmio Turing e provavelmente até um Oscar e um Grammy por tudo o que vale a pena.