¿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 comoiniciar
y la simulación se inicia cuando llamas apilas.ejecutar
(que se encarga de llamar aejecutar_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
.