¿Cómo funciona pilas por dentro?

NOTA: Esta sección describe el funcionamiento interno de la versión 0.83 de pilas-engine, que si bien no es la versión más reciente, muchas instrucciones son útiles para entender el funcionamiento interno de la aplicación.

Pilas es un proyecto con una arquitectura de objetos grande. Tiene mucha funcionalidad, incluye un motor de física, muchos personaje pre-diseñados, eventos, escenas y un enlace al motor multimedia Qt.

Mediante este capítulo quisiera explicar a grandes rasgos los componentes de pilas. Cómo están estructurados los módulos, y qué hacen las clases mas importantes.

El objetivo es orientar a los programadores mas avanzados para que puedan investigar pilas por dentro.

Filosofía de desarrollo

Pilas es un proyecto de software libre, orientado a facilitar el desarrollo de videojuegos a personas que generalmente no hacen juegos... Por ese motivo que gran parte de las decisiones de desarrollo se tomaron reflexionando sobre cómo diseñar una interfaz de programación simple y fácil de utilizar.

Un ejemplo de ello, es que elegimos el lenguaje de programación python, y tratamos de aprovechar al máximo su modo interactivo.

API en español

Dado que pilas está orientado a principiantes, docentes y programadores de habla hispana. Preferimos hacer el motor en español, permitirle a los mas chicos usar su idioma para hacer juegos es alentador, tanto para ellos que observan que el idioma no es una barrera, como para los que enseñamos y queremos entusiasmar.

Esta es una decisión de diseño importante, porque al mismo tiempo que incluye a muchas personas, no coincide con lo que acostumbran muchos programadores (escribir en inglés).

Posiblemente en el futuro podamos ofrecer una versión de pilas alternativa en inglés, pero actualmente no es una prioridad.

Bibliotecas que usa pilas

Hay tres grandes bibliotecas que se utilizan dentro de pilas:

  • Box2D
  • Qt4

Box2D se utiliza cómo motor de física, mientras que Qt es un motor multimedia utilizado para dibujar, reproducir sonidos y manejar eventos.

Objetos y módulos

Pilas incluye muchos objetos y es un sistema complejo. Pero hay una forma sencilla de abordarlo, porque hay solamente 3 componentes que son indispensables, y han sido los pilares desde las primeras versiones de pilas hasta la fecha:

  • Mundo
  • Actor
  • Motor

Si puedes comprender el rol y las características de estos 3 componentes el resto del motor es mas fácil de analizar.

Veamos los 3 componentes rápidamente:

Mundo es un objeto singleton, hay una sola instancia de esta clase en todo el sistema y se encarga de mantener el juego en funcionamiento e interactuando con el usuario.

Los actores (clase Actor) representan a los personajes de los juegos, la clase se encarga de representar todos sus atributos como la posición y comportamiento como "dibujarse en la ventana". Si has usado otras herramientas para hacer juegos, habrás notado que se los denomina Sprites.

Luego, el Motor, permite que pilas sea un motor multimedia portable y multiplaforma. Básicamente pilas delega la tarea de dibujar, emitir sonidos y controlar eventos a una biblioteca externa. Actualmente esa biblioteca es Qt, pero en versiones anteriores ha sido implementada en pygame y sfml.

Ahora que lo he mencionado, veamos con un poco mas de profundidad lo que hace cada uno.

Inspeccionando: Mundo

El objeto de la clase Mundo se construye cuando se invoca a la función pilas.iniciar. Su implementación está en el archivo mundo.py.

Su responsabilidad es inicializar varios componentes de pilas, como el sistema de controles, la ventana, etc.

Uno de sus métodos mas importantes es ejecutar_bucle_principal. Un método que se invoca directamente cuando alguien escribe la sentencia pilas.ejecutar().

Si observas el código, notarás que es el responsable de mantener a todo el motor en funcionamiento.

Esta es una versión muy simplificada del método ejecutar_bucle_principal:

def ejecutar_bucle_principal(self, ignorar_errores=False):

    while not self.salir:
        pilas.motor.procesar_y_emitir_eventos()

        if not self.pausa_habilitada:
            self._realizar_actualizacion_logica(ignorar_errores)

        self._realizar_actualizacion_grafica()

Lo primero que debemos tener en cuenta es que este método contiene un bucle while que lo mantendrá en ejecución. Este bucle solo se detendrá cuando alguien llame al método terminar (que cambia el valor de la variable salir a True).

Luego hay tres métodos importantes:

  • procesar_y_emitir_eventos analiza el estado de los controles y avisa al resto del sistema si ocurre algo externo, como el movimiento del mouse..
  • _realizar_actualizacion_logica le permite a los personajes realizar una fracción muy pequeña de movimiento, poder leer el estado de los controles o hacer otro tipo de acciones.
  • _realizar_actualizacion_logica simplemente vuelca sobre la pantalla a todos los actores y muestra el resultado del dibujo al usuario.

Otra tarea que sabe hacer el objeto Mundo, es administrar escenas. Las escenas son objetos que representan una parte individual del juego: un menú, una pantalla de opciones, el momento de acción del juego etc...

Modo interactivo

Pilas soporta dos modos de funcionamiento, que técnicamente son muy similares, pero que a la hora de programar hacen una gran diferencia.

  • modo normal: si estás haciendo un archivo .py con el código de tu juego usarás este modo, tu programa comienza con una sentencia como iniciar y la simulación se inicia cuando llamas a pilas.ejecutar (que se encarga de llamar a ejecutar_bucle_principal del objeto mundo).

  • modo interactivo: el modo que generalmente se usa en las demostraciones o cursos es el modo interactivo. Este modo funciona gracias a una estructura de hilos, que se encargan de ejecutar la simulación pero a la vez no interrumpe al programador y le permite ir escribiendo código mientras la simulación está en funcionamiento.

Motores multimedia

Al principio pilas delegaba todo el manejo multimedia a una biblioteca llamada SFML. Pero esta biblioteca requería que todos los equipos en donde funcionan tengan aceleradoras gráficas (al menos con soporte OpenGL básico).

Pero como queremos que pilas funcione en la mayor cantidad de equipos, incluso en los equipos antiguos de algunas escuelas, reemplazamos el soporte multimedia con la biblioteca Qt. Que sabe acceder a las funciones de aceleración de gráficos (si están disponibles), o brinda una capa de compatibilidad con equipos antiguos.

La función que permite iniciar y seleccionar el motor es pilas.iniciar.

pilas.iniciar(usar_motor='qt')

Ahora bien, ¿cómo funciona?. Dado que pilas está realizado usando orientación a objetos, usamos un concepto llamado polimorfismo:

El objeto motor sabe que tiene que delegar el manejo multimedia a una instancia (o derivada) de la clase Motor (ver directorio pilas/motores/.

El motor expone toda la funcionalidad que se necesita para hace un juego: sabe crear una ventana, pintar una imagen o reproducir sonidos, entre tantas otras cosas.

El objeto mundo no sabe exactamente que motor está utilizando, solo tiene una referencia a un motor y delega en él todas las tareas multimedia.

Solo puede haber una instancia de motor en funcionamiento, y se define cuando se inicia el motor.

Sistema de actores

Los actores permiten que los juegos cobren atractivo, porque un actor puede representarse con una imagen en pantalla.

La implementación de todos los actores están en el directorio pilas/actores.

Todos los actores heredan de la clase Actor, que define el comportamiento común de todos los actores.

Por ejemplo, esta sería una versión reducida de la jerarquía de clases de los actores Mono, Pingu y Tortuga.

Hay dos métodos en los actores que se invocarán en todo momento: el método actualizar se invocará cuando el bucle de juego del mundo llame al método _realizar_actualizacion_logica, esto ocurre unas 60 veces por segundo. Y el otro método es dibujar, que se también se invoca desde el objeto mundo, pero esta vez en el método _realizar_actualizacion_grafica.

Modo depuración

Cuando pulsas teclas como F8, F9, F10, F11 o F12 durante la ejecución de pilas, vas a ver que la pantalla comienza a mostrar información valiosa para los desarrolladores.

Esta modalidad de dibujo la llamamos modo depuración, y ayuda mucho a la hora de encontrar errores o ajustar detalles.

El objeto Mundo, que mantiene en ejecución al juego, tiene una instancia de objeto Depurador que se encarga de hacer estos dibujos.

Las clases mas importantes a la hora de investigar el depurador están en el archivo depurador.py.

El Depurador tiene dos atributos, tiene una pizarra para dibujar y una lista de modos. Los modos pueden ser cualquiera de los que están en la jerarquía de ModoDepuracion, por ejemplo, podría tener instancias de ModoArea y ModoPuntoDeControl.

Sistema de eventos

Hay varios enfoques para resolver el manejo de eventos en los videojuegos.

Pilas usa un modelo conocido y elaborado llamado Observator, un patrón de diseño. Pero que lamentablemente no es muy intuitivo a primera vista.

En esta sección intentaré mostrar por qué usamos esa solución y qué problemas nos ayuda a resolver.

Comenzaré explicando sobre el problema de gestionar eventos y luego cómo el modelo Observator se volvió una buena solución para el manejo de eventos.

El problema: pooling de eventos

Originalmente, en un modelo muy simple de aplicación multimedia, manejar eventos de usuario es algo sencillo, pero con el tiempo comienza a crecer y se hace cada vez mas difícil de mantener.

Resulta que las bibliotecas multimedia suelen entregar un objeto evento cada vez que ocurre algo y tu responsabilidad es consultar sobre ese objeto en búsqueda de datos.

Imagina que quieres crear un actor Bomba cada vez que el usuario hace click en la pantalla. El código podría ser algo así:

evento = obtener_evento_actual()

if evento.tipo == 'click_de_mouse':
    crear_bomba(evento.x)
    crear_bomba(evento.x)
else:
    # el evento de otro tipo (teclado, ventana ...)
    # lo descartamos.

A esta solución podríamos llamarla preguntar y responder, porque efectivamente así funciona el código, primero nos aseguramos de que el evento nos importa y luego hacemos algo. En algunos sitios suelen llamar a esta estrategia pooling.

Pero este enfoque tiene varios problemas, y cuando hacemos juegos o bibliotecas se hace mas evidente. El código, a medida que crece, comienza a mezclar manejo de eventos y lógica del juego.

Para ver el problema de cerca, imagina que en determinadas ocasiones quieres deshabilitar la creación de bombas, ¿cómo harías?. ¿Y si quieres que las bombas creadas se puedan mover con el teclado?.

Otro enfoque, en pilas usamos 'Observator'

Hay otro enfoque para el manejo de eventos que me parece mas interesante, y lo he seleccionado para el motor pilas:

En lugar de administrar los eventos uno a uno por consultas, delegamos esa tarea a un sistema que nos permite suscribir y ser notificado.

Aquí no mezclamos nuestro código con el sistema de eventos, si queremos hacer algo relacionado con un evento, escribimos una función y le pedimos al evento que llame a nuestra función cuando sea necesario.

Veamos el ejemplo anterior pero usando este enfoque, se creará una Bomba cada vez que el usuario hace click en la pantalla:

def crear_bomba(evento):
    pilas.actores.Bomba(x=evento.x, y=evento.y)
    return true

pilas.eventos.click_de_mouse.conectar(crear_bomba)

Si queremos que el mouse deje de crear bombas, podemos ejecutar la función desconectar:

pilas.eventos.click_de_mouse.conectar(crear_bomba)

o simplemente retornar False en la función crear_bomba.

Nuestro código tendrá bajo acoplamiento con los eventos del motor, y no se nos mezclarán.

De hecho, cada vez que tengas dudas sobre las funciones suscritas a eventos pulsa F7 y se imprimirán en pantalla.

¿Cómo funciona?

Ahora bien, ¿cómo funciona el sistema de eventos por dentro?:

El sistema de eventos que usamos es una ligera adaptación del sistema de señales de django (un framework para desarrollo de sitios web) dónde cada evento es un objeto que puede hacer dos cosas:

  • suscribir funciones.
  • invocar a las funciones que se han suscrito.

1 Suscribir

Por ejemplo, el evento mueve_mouse es un objeto, y cuando invocamos la sentencia pilas.eventos.mueve_mouse.conectar(mi_funcion), le estamos diciendo al objeto "quiero que guardes una referencia a mi_funcion".

Puedes imaginar al evento como un objeto contenedor (similar a una lista), que guarda cada una de las funciones que le enviamos con el método conectar.

2 Notificar

La segunda tarea del evento es notificar a todas las funciones que se suscribieron.

Esto se hace, retomando el ejemplo anterior, cuando el usuario hace click con el mouse.

Los eventos son objetos Signal y se inicializan en el archivo eventos.py, cada uno con sus respectivos argumentos o detalles:

click_de_mouse = Evento("click_de_mouse")
pulsa_tecla = Evento("pulsa_tecla")
[ etc...]

Los argumentos indican información adicional del evento, en el caso del click, observarás que los argumentos son el botón pulsado y la coordenada del puntero.

Cuando se quiere notificar a las funciones conectadas a un evento simplemente se tiene que invocar al método emitir del evento y proveer los argumentos que necesita:

click_de_mouse.emitir(button=1, x=30, y=50)

Eso hará que todas las funciones suscritas al evento click_de_mouse se invoquen con el argumento evento representando esos detalles:

def crear_bomba(evento):

    print(evento.x)
    # imprimirá 30

    print(evento.y)
    # imprimirá 50

    [ etc...]

La parte de pilas que se encarga de llamar a los métodos emitir es el método procesar_y_emitir_eventos del motor.

Habilidades

Los actores de pilas tienen la cualidad de poder ir obteniendo comportamiento desde otras clases.

Esto te permite lograr resultados de forma rápida, y a la vez, es un modelo tan flexible que podrías hacer muchos juegos distintos combinando los mismos actores pero con distintas habilidades.

Veamos un ejemplo, un actor sencillo como Mono no hace muchas cosas. Pero si escribimos lo siguiente, podremos controlarlo con el mouse:

mono = pilas.actores.Mono()
mono.aprender(pilas.habilidades.Arrastrable)

Lo que en realidad estamos haciendo, es vincular dos objetos en tiempo de ejecución. mono es un objeto Actor, y tiene una lista de habilidades que puede aumentar usando el método aprender.

El método aprender toma la clase que le enviamos como argumento, construye un objeto y lo guarda en su lista de habilidades.

Este es un modelo de cómo se conocen las clases entre sí:

Entonces, una vez que invocamos a la sentencia, nuestro actor tendrá un nuevo objeto en su lista de habilidades, listo para ejecutarse en cada cuadro de animación.

¿Cómo se ejecutan las habilidades?

Retomando un poco lo que vimos al principio de este capítulo, lo que mantiene con vida al juego es el bucle principal, la clase Mundo tiene un bucle que recorre la lista de actores en pantalla y por cada uno llama al método actualizar.

Bien, las habilidades se mantienen en ejecución desde ahí también. Esta es una versión muy simplificada del bucle que encontrarás en el archivo ``mundo.py```:

def ejecutar_bucle_principal(self, ignorar_errores=False):

    while not self.salir:
        self.actualizar_actores()

        [ etc ...]

def actualizar_actores(self):
    for actor in pilas.actores.todos:
        actor.actualizar()
        actor.actualizar_habilidades()

Aquí puedes ver dos llamadas a métodos del actor, el método actualizar se creó para que cada programador escriba ahí lo que quiera que el personaje haga (leer el teclado, hacer validaciones, moverse etc). Y el método actualizar_habilidades es el encargado de dar vida a las habilidades.

Técnicamente hablando, el método actualizar_habilidades es muy simple, solamente toma la lista de objetos habilidades y los actualiza, al Actor no le preocupa en lo mas mínimo "qué" hace cada habilidad, solamente les permite ejecutar código (ver código estudiante.py, una superclase de Actor):

def actualizar_habilidades(self):
    for h in self.habilidades:
        h.actualizar()

Entonces, si queremos que un actor haga muchas cosas, podemos crear un objeto habilidad y vincularlo con el actor. Esto permite generar "comportamientos" re-utilizables, la habilidad se codifica una vez, y se puede usar muchas veces.

Objetos habilidad

Las habilidades interactúan con los actores, y por ese motivo tienen que tener una interfaz en común, de modo tal que desde cualquier parte de pilas puedas tratar a una habilidad como a cualquier otra.

La interfaz que toda habilidad debe tener es la que define la clase Habilidad del archivo habilidades.py:

class Habilidad:

    def __init__(self, receptor):
        self.receptor = receptor

    def actualizar(self):
        pass

    def eliminar(self):
        pass

Tiene que tener tres métodos, uno que se ejecuta al producirle la relación con un actor, un método que se ejecutará en cada iteración del bucle de juego (actualizar) y un último método para ejecutar cuando la habilidad se desconecta del actor. Este método eliminar suele ser el que desconecta eventos o cualquier otra cosa creada temporalmente.

Ten en cuenta que el método __init__, que construye al objeto, lo invoca el propio actor desde su método aprender. Y el argumento receptor será una referencia al actor que aprende la habilidad.

Veamos un ejemplo muy básico, imagina que quieres hacer una habilidad muy simple, que gire al personaje todo el tiempo, cómo una aguja de reloj. Podrías hacer algo así:

class GirarPorSiempre(pilas.habilidades.Habilidad):

    def __init__(self, receptor):
        self.receptor = receptor

    def actualizar(self):
        self.receptor.rotacion += 1

mono = pilas.actores.Mono()
mono.aprender(GirarPorSiempre)

La sentencia aprender construirá un objeto de la clase que le indiquemos, y el bucle de pilas (en mundo.py) dará la orden para ejecutar los métodos actualizar de cada habilidad conocida por los actores.

Argumentos de las habilidades

En el ejemplo anterior podríamos encontrar una limitación. El actor siempre girará a la misma velocidad.

Si queremos que los personajes puedan girar a diferentes velocidades tendríamos que agregarle argumentos a la habilidad, esto es simple: solo tienes que llamar al método aprender con los argumentos que quieras y asegurarte de que la habilidad los tenga definidos en su método __init__.

Este es un ejemplo de la habilidad pero que permite definir la velocidad de giro:

class GirarPorSiempre(pilas.habilidades.Habilidad):

    def __init__(self, receptor, velocidad=1):
        self.receptor = receptor
        self.velocidad = velocidad

    def actualizar(self):
        self.receptor.rotacion += self.velocidad

a = pilas.actores.Mono()
a.aprender(GirarPorSiempre, 20)

Listo, es casi idéntico al anterior, si llamas a aprender con un argumento como 20, el actor girará mucho mas rápido que antes. Y si no especificas la velocidad, se asumirá que la velocidad es 1, porque así lo indica el método __init__.

Documentación

El sistema de documentación que usamos en pilas es Sphinx, un sistema muy interesante porque nos permite gestionar todo el contenido del manual en texto plano, y gracias a varias herramientas de conversión cómo restructuredText y latex, se producen muchos formatos de salida cómo HTML y PDF.

Toda la documentación del proyecto está en el directorio doc. El directorio doc/sources contiene todos los archivos que modificamos para escribir contenido en la documentación.

Para generar los archivos PDF o HTML usamos el comando make dentro del directorio doc. El archivo que dispara todas las acciones que sphinx sabe hacer están definidas en el archivo Makefile.