Puede ver su sistema como si estuviera compuesto por una serie de estados y funciones, donde una función f[j]
con entrada x[j]
cambia el estado del sistema s[j]
a estado s[j+1]
, de la siguiente manera:
s[j+1] = f[j](s[j], x[j])
Un estado es la explicación de todo tu mundo. La ubicación del jugador, la ubicación del enemigo, el puntaje, la munición restante, etc. Todo lo que necesita para dibujar un marco de su juego.
Una función es cualquier cosa que pueda afectar al mundo. Un cambio de marco, una pulsación de tecla, un paquete de red.
La entrada son los datos que toma la función. Un cambio de fotograma puede llevar la cantidad de tiempo transcurrido desde que pasó el último fotograma, la pulsación de tecla puede incluir la tecla real presionada, así como si se presionó o no la tecla Mayús.
En aras de esta explicación, haré los siguientes supuestos:
Supuesto 1:
La cantidad de estados para una ejecución determinada del juego es mucho mayor que la cantidad de funciones. Probablemente tenga cientos de miles de estados, pero solo una docena de funciones (cambio de trama, pulsación de teclas, paquete de red, etc.). Por supuesto, la cantidad de entradas debe ser igual a la cantidad de estados menos uno.
Supuesto 2:
El costo espacial (memoria, disco) de almacenar un solo estado es mucho mayor que el de almacenar una función y su entrada.
Supuesto 3:
El costo temporal (tiempo) de presentar un estado es similar, o solo uno o dos órdenes de magnitud más largos que el de calcular una función sobre un estado.
Dependiendo de los requisitos de su sistema de reproducción, hay varias formas de implementar un sistema de reproducción, por lo que podemos comenzar con el más simple. También haré un pequeño ejemplo usando el juego de ajedrez, grabado en pedazos de papel.
Método 1:
Tienda s[0]...s[n]
. Esto es muy simple, muy sencillo. Debido a la suposición 2, el costo espacial de esto es bastante alto.
Para el ajedrez, esto se lograría dibujando todo el tablero para cada movimiento.
Método 2:
Si solo necesita la reproducción hacia adelante, simplemente puede almacenar s[0]
y luego almacenar f[0]...f[n-1]
(recuerde, este es solo el nombre de identificación de la función) y x[0]...x[n-1]
(cuál fue la entrada para cada una de estas funciones). Para volver a jugar, simplemente comienza s[0]
y calcula
s[1] = f[0](s[0], x[0])
s[2] = f[1](s[1], x[1])
y así...
Quiero hacer una pequeña anotación aquí. Varios otros comentaristas dijeron que el juego "debe ser determinista". Cualquiera que diga que necesita tomar Computer Science 101 nuevamente, porque a menos que su juego esté destinado a ejecutarse en computadoras cuánticas, TODOS LOS PROGRAMAS DE COMPUTADORA SON DETERMINISTOS¹. Eso es lo que hace que las computadoras sean tan increíbles.
Sin embargo, dado que su programa probablemente depende de programas externos, desde bibliotecas hasta la implementación real de la CPU, puede ser bastante difícil asegurarse de que sus funciones se comporten de la misma manera entre plataformas.
Si usa números pseudoaleatorios, puede almacenar los números generados como parte de su entrada x
o almacenar el estado de la función prng como parte de su estado s
y su implementación como parte de la función f
.
Para el ajedrez, esto se lograría dibujando el tablero inicial (que se conoce) y luego describirá cada movimiento diciendo qué pieza fue a dónde. Así es como lo hacen, por cierto.
Método 3:
Ahora, lo más probable es que desees poder buscar tu repetición. Es decir, calcular s[n]
para un arbitrario n
. Al utilizar el método 2, debe calcular s[0]...s[n-1]
antes de poder calcular s[n]
, lo que, según el supuesto 2, puede ser bastante lento.
Para implementar esto, el método 3 es una generalización de los métodos 1 y 2: almacenar f[0]...f[n-1]
y x[0]...x[n-1]
al igual que el método 2, pero también almacenar s[j]
, para todos, j % Q == 0
para una constante dada Q
. En términos más fáciles, esto significa que almacena un marcador en uno de cada Q
estados. Por ejemplo, para Q == 100
, usted almacenas[0], s[100], s[200]...
Para calcular s[n]
un arbitrario n
, primero carga el almacenado previamente s[floor(n/Q)]
y luego calcula todas las funciones de floor(n/Q)
a n
. A lo sumo, estarás calculando Q
funciones. Los valores más pequeños de Q
son más rápidos de calcular pero consumen mucho más espacio, mientras que los valores más grandes de Q
consumen menos espacio, pero tardan más en calcularse.
El método 3 con Q==1
es el mismo que el método 1, mientras que el método 3 con Q==inf
es el mismo que el método 2.
Para el ajedrez, esto se lograría dibujando cada movimiento, así como uno de cada 10 tableros (para Q==10
).
Método 4:
Si desea reproducción inversa, se puede hacer una pequeña variación del método 3. Supongamos Q==100
, y si desea calcular s[150]
a través de s[90]
a la inversa. Con el método 3 no modificado, necesitará hacer 50 cálculos para obtener s[150]
y luego 49 cálculos más para obtener s[149]
y así sucesivamente. Pero dado que ya calculó s[149]
obtener s[150]
, puede crear un caché s[100]...s[150]
cuando calcule s[150]
por primera vez, y luego ya está s[149]
en el caché cuando necesita mostrarlo.
Solo necesita regenerar el caché cada vez que necesita calcular s[j]
, j==(k*Q)-1
para cualquier momento k
. Esta vez, el aumento Q
dará como resultado un tamaño más pequeño (solo para el caché), pero tiempos más largos (solo para recrear el caché). Se Q
puede calcular un valor óptimo para si conoce los tamaños y tiempos necesarios para calcular estados y funciones.
Para el ajedrez, esto se lograría dibujando cada movimiento, así como uno de cada 10 tableros (para Q==10
), pero también, requeriría dibujar en un pedazo de papel separado, los últimos 10 tableros que ha calculado.
Método 5:
Si los estados simplemente consumen demasiado espacio o las funciones consumen demasiado tiempo, puede crear una solución que realmente implemente (no falsifique) la reproducción inversa. Para hacer esto, debe crear funciones inversas para cada una de las funciones que tiene. Sin embargo, esto requiere que cada una de sus funciones sea una inyección. Si esto es factible, entonces para f'
denotar el inverso de la función f
, calcular s[j-1]
es tan simple como
s[j-1] = f'[j-1](s[j], x[j-1])
Tenga en cuenta que aquí, la función y la entrada son ambas j-1
, no j
. Esta misma función y entrada serían las que habría utilizado si estuviera calculando
s[j] = f[j-1](s[j-1], x[j-1])
Crear la inversa de estas funciones es la parte difícil. Sin embargo, generalmente no puede hacerlo, ya que algunos datos de estado generalmente se pierden después de cada función en un juego.
Este método, tal como está, puede realizar el cálculo inverso s[j-1]
, pero solo si lo tiene s[j]
. Esto significa que solo puede ver la reproducción al revés, comenzando desde el punto en el que decidió reproducirla al revés. Si desea reproducir hacia atrás desde un punto arbitrario, debe mezclar esto con el método 4.
Para el ajedrez, esto no se puede implementar, ya que con un tablero dado y el movimiento anterior, puede saber qué pieza se movió, pero no de dónde se movió.
Método 6:
Finalmente, si no puede garantizar que todas sus funciones sean inyecciones, puede hacer un pequeño truco para hacerlo. En lugar de hacer que cada función devuelva solo un nuevo estado, también puede hacer que devuelva los datos que descartó, así:
s[j+1], r[j] = f[j](s[j], x[j])
¿Dónde r[j]
están los datos descartados? Y luego cree sus funciones inversas para que tomen los datos descartados, así:
s[j] = f'[j](s[j+1], x[j], r[j])
Además de f[j]
y x[j]
, también debe almacenar r[j]
para cada función. Una vez más, si desea poder buscar, debe almacenar marcadores, como con el método 4.
Para el ajedrez, esto sería lo mismo que el método 2, pero a diferencia del método 2, que solo dice qué pieza va a dónde, también debe almacenar de dónde vino cada pieza.
Implementación:
Dado que esto funciona para todo tipo de estados, con todo tipo de funciones, para un juego específico, puede hacer varias suposiciones, lo que facilitará la implementación. En realidad, si implementa el método 6 con todo el estado del juego, no solo podrá reproducir los datos, sino que también retrocederá en el tiempo y reanudará la reproducción desde cualquier momento. Eso sería bastante asombroso.
En lugar de almacenar todo el estado del juego, simplemente puede almacenar el mínimo necesario que necesita para dibujar un estado dado y serializar estos datos cada cantidad fija de tiempo. Sus estados serán estas serializaciones, y su entrada ahora será la diferencia entre dos serializaciones. La clave para que esto funcione es que la serialización debería cambiar poco si el estado mundial también cambia poco. Esta diferencia es completamente reversible, por lo que es muy posible implementar el método 5 con marcadores.
He visto esto implementado en algunos juegos importantes, principalmente para la reproducción instantánea de datos recientes cuando ocurre un evento (un fragmento en fps o una puntuación en juegos deportivos).
Espero que esta explicación no haya sido demasiado aburrida.
¹ Esto no significa que algunos programas actúen como si no fueran deterministas (como MS Windows ^^). Ahora en serio, si puedes hacer un programa no determinista en una computadora determinista, puedes estar seguro de que ganarás simultáneamente la medalla Fields, el premio Turing y probablemente incluso un Oscar y un Grammy por todo lo que vale.