Implementando un sistema de replay en tu videojuego

En términos de almacenamiento, todo el input de una partida a un videojuego cualquiera (es decir, todos los botones o ejes cuyo estado es modificado y en qué intervalos) es, por lo general, relativamente pequeño. A excepción de los juegos cuyas partidas puedan prolongarse potencialmente durante horas (o días) y en los que almacenar toda la información relevante para una posterior reproducción ocuparía gigas, suele existir un sistema de replay para que podamos revivir, analizar, o enseñar a terceros nuestras hazañas. Me vienen a la mente los replays del Warcraft 3 que ocupaban un puñado de kilobytes, a pesar de ser un juego cuyas partidas podían prolongarse más allá de la media hora y en los que a lo largo del juego se podían generar cientos o miles de unidades que ejecutaban órdenes muy diversas*.

El primer requisito para poder implementar un sistema de replay en el que el tamaño de fichero sea mínimo es que todos los subsistemas relevantes sean deterministas, y especialmente las físicas. Yo ya sospechaba que Box2D lo es**, puesto que al ejecutar varias veces una simulación con los mismos datos iniciales la ejecución siempre terminaba en un estado aparentemente igual, pero el señor Catto lo confirma él mismo, siempre que se utilice, obviamente, el mismo build.

Una opción que consideré inicialmente fue, para cada frame de la partida, almacenar el estado de todos los elementos e ir inyectándolos frame a frame, lógicamente después de haber desactivado la entrada de input. Esto supone almacenar el estado de cientos de elementos por cada frame. Un disparate si no utilizamos algún método de compresión.

Para una partida de, supongamos, cinco minutos, tendríamos unos 18000 frames (5 minutos * 60 segundos por minuto * 60 frames por segundo si tenemos una tasa consistente de frames) con un simple array de 256 booleanos (y aquí conviene recordar que en C/C++ el bool ocupa un byte de memoria, no un bit) ya nos vamos a más de cuatro megas de replay. Y esto solamente para la información de input, sin entrar en otro tipo de información que el juego genere en tiempo de ejecución y sea necesaria para una reproducción fiel.

Una solución más compleja, pero también más económica, pasa por almacenar exclusivamente los cambios de estado que tengan lugar a lo largo de la partida en el input. Por ejemplo, podríamos utilizar un vector que contenga tantos elementos como frames en los que haya habido cambio de estado de algún elemento de input durante la partida, y en cada uno de esos elementos almacenaríamos únicamente el estado de los elementos de input relevantes y no de todos (es decir, si usamos cuatro botones, el estado de esos cuatro botones) en una máscara de bits de dos bytes***, junto con el número de frame en el que hubo cambio. Suponiendo una partida con mil cambios de estado del input, y un entero sin signo de dos bytes para almacenar el frame en el que hay cambio, tendremos 4 bytes * 1000 cambios de input = 4kb. Bastante más asequible. Si te fijas, hemos desacoplado el tamaño de almacenamiento del número de frames. La partida podría durar años, pero sin cambios en el input, el fichero de replay ocuparía 4 bytes^.

Por otro lado, raramente el replay de una partida podrá ser calculado a partir del input solamente. En nuestros juegos nos gusta introducir aleatoriedad. Por ejemplo, en el nivel 3 de la mazmorra a veces querremos que al héroe le aparezca un troll y otras veces una babosa infernal (no, no es el momento adecuado para hacer un chiste sobre tu ex). Esa aleatoriedad puede ser reproducida  en función del algoritmo de generación de números aleatorios que elijamos, pero por lo general va a ser más productivo que simplemente almacenemos el resultado de esa generación y lo reproduzcamos en el replay.

En resumen, necesitamos:

a) Un sistema que almacene todos los datos necesarios para la reproducción fiel de la partida: input y elementos generados aleatoriamente.

b) Un sistema que en el curso de una partida por un lado inyecte el input almacenado en los frames correspondientes y por otro reemplace lo que habitualmente sería generado a partir de un número aleatorio por otra información que sabemos que fue generada en la partida que queremos reproducir.

Por tanto, para realizar la implementación necesitamos:

  1. Definir una estructura sencilla para almacenar estado de los controladores y frame en que se produjo el cambio (frameControllerData):
    typedef struct {
        uint16_t frame;
        uint16_t controllerState;
    } frameControllerData;
  2. Definir una equivalencia entre elemento de controlador y bit que almacenará su estado (por ejemplo, el estado de LEFT_KEY se almacenaría en el bit 0 de controllerState, SPACE_KEY en el bit 1, etc):
  3. Crear un contenedor que almacenará todos los frameControllerData:
    std::vector<framecontrollerdata> m_replayData;
  4. Crear estructuras adicionales que almacenen otros datos necesarios para la reproducción (por ejemplo, si en el frame 78823 de la partida hemos generado aleatoriamente un monstruo, debemos almacenar este suceso y todas sus características – como posición, vida, arma que porta, etc – y luego recrearlo en el replay reemplazando al generador de monstruos). Simplificando mucho, el código sería algo parecido a esto:
    
    if (!replay) {
    
    monsterGenerator->addRandomMonster(world);
    
    } else {
    
    replayManager->addMonster(currentFrame);
    
    }
    
    

    Estas estructuras ya dependen de tu juego en concreto y no tendría sentido definirlas aquí, salvo a modo de ejemplo.

  5. Introducir en el gestor de eventos de input la operación condicional de almacenamiento de cambios de estado (en mi juego irá en los eventos keydown y keyup, pero puede variar en función del tipo de controlador que el juego use). En mi caso uso SDL, y el código queda así:
    void inputStruct::handleEvent(SDL_Event e) {
    switch(e.type) {
    [...]
    case SDL_KEYDOWN:
    
    case SDL_KEYUP:
    {
    // handle key events...
    }
    if (m_replayActive && e.key.repeat == 0)
    {
    
    // store input for replay
    
    replay.storeFrameData(frame, keyState);
    
    }
    
    break;
  6. Almacenar los datos del contenedor en un fichero una vez finalizada la partida. Las clases de stream son las que nos van a ayudar a hacerlo.
  7. Crear dentro de la clase Replay la funcionalidad para inyectar al gestor de input los datos que hemos almacenado en (3). Habitualmente lo que haremos será permitir que el bucle principal gestione el input como siempre y al principio de la actualización inyectemos el estado del input proveniente del replay en la estructura si estamos en el frame correspondiente. De ese modo todas las comprobaciones que se hagan del input se corresponderán con las del replay.
  8. Modificar aquellas partes del código que generen objetos de manera aleatoria para que puedan obtener los datos de la(s) estructura(s) de (4), de un modo similar al de (7).

Hay que tener en cuenta que esta solución está orientada a mi motor e indudablemente va a variar en tu implementación, aunque el razonamiento básico debería ser el mismo.

Para terminar, recomiendo leer este artículo de gamasutra que es un poco más extenso, pues profundiza en aspectos que yo no he tocado como compresión y gestión de deltas, aunque cuando fue escrito los requisitos de memoria eran mucho más restrictivos que en la actualidad.

 

 

*Cierto es que los señores de Blizzard saben muy bien lo que se hacen, pero esto debería servir como ejemplo de hasta qué punto los datos de input, debidamente almacenados, son insignificantes en términos de espacio.

**Si utilizas un motor como Unity o UE, es más que probable que ya traiga o esté disponible desde third parties un componente de replays con lo que este tutorial te serviría sólo en caso de que te decidas a implementarlo tú mismo.

***Podríamos usar un solo byte, pero no tendría sentido. La alineación (padding en inglés) nos obligaría a seguir gastando un byte adicional, y estaríamos tirando a la basura la capacidad de almacenar ocho elementos más de input.

^Puesto que el estado inicial siempre lo vamos a almacenar. Sin embargo, el sistema de ficheros de tu sistema operativo siempre va a obligar al fichero a ocupar típicamente un mínimo de 4kB en disco.

Advertisements
Implementando un sistema de replay en tu videojuego

C#: detectando cuando una ventana WPF es minimizada

Tanto si utilizamos un form como si utilizamos una ventana WPF la propiedad que tenemos que comprobar es WindowState. La diferencia viene en que un form el evento que se dispara al minimizar es _Resize y en una ventana WPF es _StateChanged.

En mi código quiero minimizar el form que contiene la barra de herramientas si minimizo la ventana principal, e igualmente quiero devolverla a su posición si maximizo o devuelvo a su posición a la principal.

Capture

Fuente: StackOverflow

C#: detectando cuando una ventana WPF es minimizada

Visual C#: obtener resolución del escritorio

La clase System.Windows.Forms.Screen nos ofrece la propiedad Screen.AllScreens que nos da acceso a una colección de todas las pantallas detectadas por el sistema. La propiedad Screen.PrimaryScreen nos da acceso a la pantalla principal.

La resolución la obtenemos de la propiedad Bounds del objeto:

Rectangle resolution = Screen.PrimaryScreen.Bounds;

La propiedad Screen.PrimaryScreen.WorkingArea nos ofrece el tamaño del área de trabajo.

Obtenido de stack overflow.

Visual C#: obtener resolución del escritorio

¿Cuándo usar el registro de Windows en vez de ficheros?

A la hora de crear una aplicación, a menudo nos vamos a encontrar que necesitamos reutilizar datos de una sesión anterior, una característica que dentro del mundo de la informática se conoce como persistencia. Lo más habitual es trabajar con ficheros, ya que al fin y al cabo el disco duro nos ofrece exactamente eso, la capacidad de almacenar datos que sobrevivan no sólo al ciclo de vida de la aplicación sino al apagado del ordenador.

Debemos tener en cuenta, sin embargo, que Windows nos ofrece una base de datos integrada en el sistema que también nos permite almacenar información pero con algunas ventajas:

  • Cada usuario tiene su propio registro. Esto implica que si queremos almacenar datos por usuario, no tenemos que crear nosotros mismos un sistema de usuarios, de permisos ni de absolutamente nada.
  • Ofrece una interfaz de clave/registro que no necesita ser parseada. Podemos almacenar datos arbitrarios en el registro sin tener que andar insertando separadores.
  • El sistema operativo nos ofrece una interfaz gráfica de gestión. Utilizando regedit podemos crear claves, modificarlas y borrarlas.
  • El registro es gestionado por el kernel. Si tu aplicación se cuelga o tiene cualquier problema, los cambios que hayas hecho en el registro seguirán estando ahí. Si el sistema entero se cuelga, es responsabilidad de Windows asegurarse de que el registro es válido.
  • El registro es seguro para hilos. Varios hilos o procesos pueden estar accediendo a la misma clave y podremos asegurar que las modificaciones son atómicas.

Por supuesto, el registro presenta también desventajas:

  • No vale para una gran cantidad de datos. El registro no está diseñado para guardar ese nivel de dos gigas que has creado, sino para almacenar unos pocos parámetros de configuración.
  • Dificultad de manejo. Al ser un enorme bloque de bytes, es difícil de gestionar y de crear backups.
  • Escasa portabilidad. Si me quiero llevar mi aplicación a otro escritorio, no me queda otro remedio que andar exportando claves, frente a la opción de tener un fichero de configuración que pertenezca a los ficheros de la propia aplicación.

Si utilizamos C#, la clase Registry (que podemos encontrar en el namespace Microsoft.Win32) nos facilita operaciones para trabajar con él.

Sacado de éste enlace.

¿Cuándo usar el registro de Windows en vez de ficheros?

Drupal: redireccionar usuarios a una página

Existe un módulo específicamente diseñado para redireccionar a un usuario a una determinada página llamado Login Destination (http://drupal.org/project/login_destination).

Otro módulo es LoginToBoggan.

Otra forma (y esto solo funciona desde /user) es agregar la siguiente cadena al link de iniciar sesión:

http://example.com/user?destination=/ 

Eso hace que cuando el usuario inicie sesión desde la página user, sea redireccionado a la página inicial. Lo malo es que esto no funciona para bloques.

Drupal: redireccionar usuarios a una página

MICROSOFT SQL SERVER 2005: Haciendo un BACKUP

Para crear copias de seguridad desde el administrador corporativo, seleccionamos la base de datos de la que queremos crear una copia de seguridad y con el botón derecho del ratón seleccionamos Todas las tareas -> Copia de seguridad de la base de datos (imagen 5).

Podemos decir que clase de copia queremos hacer:

  •  Base de datos completa.
  • Diferencial, esta forma solo copiara los cambios realizados desde la última copia.
  • Copiar el registro de transacciones.

 

Seleccionamos “agregar” y saldrá una pantalla donde especificaremos la ruta y el nombre del fichero que queremos utilizar como copia, el fichero puede ser de nueva creación o uno existente, o podemos especificar un dispositivo ya existente o crear uno nuevo. Un dispositivo de copia de seguridad no es más que un archivo igual que el que hubieramos seleccionado en la opción superior, pero estos a diferencia de los anteriores, podemos adminístralo y ver su contenido desde la opción del nodo Administración -> Copias de seguridad. Una vez seleccionado donde alojaremos la copia, debemos elegir si sobrescribimos las copias que tenga el fichero o la nueva copia se anexa a las ya existentes en el fichero (en caso de ser un fichero con copias existentes). Una vez hecho ésto seleccionamos la ficha “opciones” y habilitamos la opción “Comprobar la copia de seguridad al concluir”, para que una vez finalizada la copia de seguridad compruebe si se ha realizado correctamente.

Como he comentado antes podemos hacer copias de seguridad a través de programación con Transact-SQL o SQL-DMO. Con transact-SQL utilizaremos la instrucción “Backup” para realizar la copia de seguridad (en los libros en pantalla del SQL Server encontrareis amplia información sobre esta instrucción).

Ejemplo con Transact-SQL:

USE master

EXEC sp_addumpdevice ‘disk’, ‘pruebas_1’, 

‘G:\microsoft sql server espanol\MSSQL$TORMENTA\BACKUP\BACKUP.dat’

 

— Backup de la base de datos

BACKUP DATABASE PRUEBAS TO Pruebas_1

¿Qué hace este código?, explicando línea a línea lo primero que hacemos es decirle al stored procedure que vamos a utilizar la base de datos “Master”.

USE Master — Utilizamos la base de datos “Master” para ejecutar la primera sentencia

porque el procedimiento almacenado que ejecutamos para crear un dispositivo esta alojado en la base de datos “Master”

EXEC sp_ addumpdevice utilizamos éste procedimiento almacenado de la base de datos “Master” para crear un dispositivo de copia de seguridad. Parámetros que recibe:

disk, especifica que el archivo de seguridad será un archivo de disco.

pruebas_1, nombre lógico que le damos al dispositivo de copia y es el que se verá desde el administrador de copias.

ruta, ruta física donde se alojará el fichero en disco, ruta y nombre del fichero.

BACKUP , a continuación hacemos un Backup (copia de seguridad) de la base de datos PRUEBAS al dispositivo Pruebas_1.

Lo explicado hasta el momento sobre las copias de seguridad esta muy bien y es muy útil, pero, muy pesado si cada día tenemos que entrar en el administrador corporativo para hacer una copia de seguridad, y muy peligroso si nos olvidamos, por eso hay lo que se llama “programación de copias de seguridad”, con esta utilidad programamos la tarea de copias de seguridad para que se realicen solas en una determinada fecha. 

Para programar el día y hora que se realizarán las copias realizamos las misma operaciones que para crear una copia de seguridad pero tenemos que habilitar la opción “programar”, en la imagen 5 podemos ver esa opción al final de la pantalla, pulsando el botón podremos seleccionar día y hora para que la copia de seguridad se ejecute sola (imagen 7). Esta tarea queda guardad en la opción del Agente de SQL Server “trabajos”.

Restaurar copias de seguridad

Si se pueden hacer copias de seguridad lo lógico es que estas copias se puedan restaurar. Para restaurar una copia de seguridad tenemos que seleccionar la base de datos deseada y con el botón derecho pulsamos en la opción Todas las tareas -> Restaurar base de datos.

En esta pantalla, si restauramos la base de datos desde un fichero, seleccionamos “desde dispositivo”, pulsamos el botón “dispositivo” y en la siguiente pantalla pulsamos “agregar” y seleccionamos el fichero de copias de seguridad que queremos restaurar. Las opciones de restauración son las mismas que las de copias, podemos restaurar la base de datos:

 

– Completa

– Diferencial

– O el registro de transacciones.

 

En la siguiente ficha de esta pantalla “opciones”, seleccionamos las opciones de restauración de la copia:

 

Forzar restauración sobre la base de datos existente, forzamos a que se restaure la copia sobrescribiendo el fichero existente.

Restaurar archivos de base de datos como, especifica el nombre del archivo físico y lógico de la restauración.
Del mismo modo que podemos hacer copias de seguridad desde código transact-SQL o SQL-DMO, podemos restaurar bases de datos. La instrucción a utilizar es “Restore database” , éste al igual que el “Backup” son amplios en parámetros y modos de uso por ese motivo lo más recomendable es mirar la ayuda de SQL Server (libros en pantalla) donde se puede encontrar un apartado completo dedicado al transact-SQL (Referencia de Transact-SQL).

También existe un programa que yo no he utilizado llamado handy Backup.

MICROSOFT SQL SERVER 2005: Haciendo un BACKUP

MS SQL 2005: Crear un disparador (TRIGGER) sobre una vista (VIEW) que mande un e-mail al DBA

Supongamos que queremos que nuestro gestor de bases de datos nos avise cuando se modifiquen ciertos datos. En este caso concreto, quiero recibir un email a mi cuenta cuando los datos de una tabla no concuerden con los datos de otra (es decir, que la cantidad de un artículo que hay en una tabla sea diferente a la cantidad de ese mismo artículo en otra tabla). Parece lioso, pero se ve rápido con un ejemplo:

TABLA datos_almacen

Codigo_articulo varchar(16)
Cantidad decimal(12,3)

TABLA datos_almacen_2

Codigo_articulo varchar(16)
Cantidad decimal(12,3)

Entonces, si la cantidad del mismo artículo es diferente para las dos tablas, quiero que me avise. Supongamos que tengo la siguiente vista que muestra todos los artículos con diferencias:

CREATE VIEW mi_vista AS
SELECT *
FROM datos_almacen a inner join datos_almacen_2 b
on a.codigo_articulo = b.codigo_articulo
and a.cantidad b.cantidad

Correcto, esta vista muestra las diferencias (recuerda que estamos suponiendo que ambas tablas tienen los mismos códigos de artículo, si en uno hubiese más que en otro habría que hacer un JOIN lateral). Ahora el trigger para que salte cuando aparezca alguna diferencia:

MS SQL 2005: Crear un disparador (TRIGGER) sobre una vista (VIEW) que mande un e-mail al DBA