dir es un videojuego multiplataforma escrito en Lua que utiliza el
framework Löve2D. El estilo de juego es parecido a Bejeweled o Tetris:
juntar piezas de un mismo color para que desaparezcan.
Es también mi intento de diseñar un juego que resulte atractivo a audiencias
casuales y hardcore, sin depender de límites de tiempo o reflejos rápidos.
Tiene esta pinta:
Si quieres descargarlo y echar una partida antes de continuar leyendo
haz click aquí.
Características
dir podría definirse tanto por las cosas que sí tiene:
Una mecánica original, basada en acumular combos.
Altamente rejugable. Una partida completa dura entre 5 y 15 minutos.
Sistema de puntuación y ranking altamente depurado.
El juego es completamente independiente de la resolución, dado que dibuja
todas las piezas vectorialmente. Todo en dir es o bien un cuadrado o bien un trozo de texto.
Tres temas de colores.
Portable. No utiliza el registro de Windows, ni la carpeta Mis Documentos, etc...
... como por las que no:
Una pantalla de título.
Múltiples niveles de dificultad.
Múltiples modos de juego.
Cargar o Guardar partida.
Sonido o música.
Límite de puntuación o final.
Pantalla de "game over".
Löve2D
Antes de empezar a programar el juego, evalué múltiples frameworks como posibles
candidatos. Los principales fueron Haxe, Löve2D, Monogame y pygame.
Dos de ellos, Monogame y pygame, los descarté en parte por sus dependencias (.NET y Python
respectivamente) y en parte por complicaciones
a la hora de hacer tareas que deberían ser realmente simples, como dibujar un cuadrado en pantalla.
En el caso de Haxe, las incompatibilidades entre la versión web y la versión
escritorio fueron la principal razón de descartarlo. Basta con echar un ojo
a la demo de PiratePig en el blog de Joshua Granick para darse cuenta de
las diferencias (compara por ejemplo la versión HTML5 y Flash).
A día de hoy no sé si puedo decir que Löve fuese el framework "perfecto", pero
volvería a utilizarlo para programar dir. La documentación
es excelente. El foro también.
Lua
Utilizar Löve2D implica utilizar Lua.
Lua es en general un lenguaje muy bien diseñado con alguna que otra pega
importante, aunque todas las pegas tienen sentido en el contexto donde Lua
suele utilizarse.
Por ejemplo: Lua no tiene una librería standard decente, no tiene excepciones,
no tiene orientación a objetos, no tiene mil cosas que normalmente son necesarias
cuando no estás programando algo embebido. Löve2D suple en parte la falta de esta
funcionalidad a base de módulos adicionales como "love.audio", "love.filesystem",
etc...
Lua tampoco tiene un modo de distinguir "nil" de "variable no inicializada", lo cual
me ha causado muchos más dolores de cabeza a la hora de programar dir que cualquier
módulo aparte que haya tenido que escribir.
Arquitectura
dir está escrito de modo que cada parte es lo más independiente posible de todas
las demás. Cada componente usa además su propia noción de orientación a objetos (closures
en realidad), con un "self" explícito al estilo Python.
Por ejemplo, para inicializar el juego:
functionGame()localself={}-- initialization:self.init=function()self.grid_width=5self.grid_height=5self.grid=Grid(self)self.hud=Hud(self)self.menu=Menu(self)self.screen=Screen(self)self.state=State(self)self.theme=Theme(self)self.resize()self.restart()end-- otros "métodos"...self.init()returnselfend
A más bajo nivel, cada módulo es responsable de sus propias animaciones.
Por ejemplo, Grid contiene todas las definiciones necesarias para que las piezas
del grid que están apareciendo, desapareciendo, moviéndose, etc mantengan su
estado entre frame y frame.
Un detalle importante aquí es que en dir solo existe una animación a la vez
en todo el grid. No es posible tener piezas que se muevan, mientras otras desaparecen
al mismo tiempo. Esto simplifica mucho la implementación, porque puede hacerse en
base al concepto de "Tween", donde cada animación se enlaza con la siguiente a través
de una función puente (ver por ejemplo grid.appearing.oncomplete).
El hilo conductor final es State, que decide qué hacer en base al ranking y la
puntuación actuales, cada vez que las piezas cambian de estado.
Finalmente, en la carpeta lib hay dos pequeñas librerías. Una para manipular arrays
de dos dimensiones y otra con wrappers sobre la funcionalidad básica de Löve2D. Esta última
es totalmente prescindible, pero hará más fácil portar dir a futuras versiones de Löve
si este cambia.
Conclusiones
Estoy muy orgulloso del resultado obtenido con dir. Creo que el resultado ha merecido el
esfuerzo. El record actual será además muy difícil de superar.
Bonus
Aquí puedes ver un vídeo de un usuario del foro de Löve2D jugando a dir:
MQLite es un pequeño dialecto basado en MQL, el lenguaje que se utiliza en
Freebase para realizar búsquedas. Está escrito en Python 3 y es un compilador
sencillo a un AST recursivo de nodos que implementan un método "match".
Aunque el núcleo de MQLite es un subset de MQL, en realidad implementa bastantes más
cosas que el original, debido a la necesidad de realizar búsquedas sobre JSON
arbitrario en lugar de una base de datos concreta.
Como lo mejor es un ejemplo... El siguiente comando devuelve la lista de repositorios
que tengo en Github con al menos 1 fork activo.
MQLite es un proyecto relativamente simple porque no necesita un parser. Utiliza la misma
sintaxis que JSON. Una vez tenemos los datos a buscar y un archivo .json donde buscarlos,
solo queda preprocesar la búsqueda para poder hacerla del modo más eficientemente posible.
Uno de los límites que me puse al implementarlo, es que MQLite nunca debería modificar
los datos JSON sobre los que busca, ni ordenándolos ni optimizándolos. Al fin y al cabo, si
realmente necesitas eso, te conviene más una base de datos real.
Si no podemos modificar los datos, solo queda representar la búsqueda. En el caso de MQLite,
está hecha a partir de una serie de nodos (objetos) con un método: "match" que devuelve o bien
una "respuesta" o "vacío" dependiendo de si los datos de la búsqueda coinciden con alguno
existente en la base de datos.
Como "None" es un resultado válido, "vacío" se representa con una clase propia:
class_NoMatch(object):""" A custom class to represent no matches. Needed because None is a legitimate match. """passNoMatch=_NoMatch()
Y "concuerda con cualquier cosa" se representa así:
classMatchAny(object):""" Match any input data. """defmatch(self,data):returndata
La mayoría de nodos son sencillos. Cadenas, números o booleanos solo concuerdan consigo mismos.
Otros datos, como las listas o los diccionarios, permiten operaciones más complejas.
Un ejemplo de búsqueda una vez transformada a esta representación, podría ser:
Dame el nombre de todos los items en la base de datos que cumplan la condición de tener una edad
mayor de 25 e imprímelos en orden ascencente.
Dificultades
La parte más complicada de escribir MQLite fue con bastante diferencia encontrar una sintaxis
adecuada para expresar las búsquedas. En el README hay numerosos ejemplos de búsquedas complejas
con directivas, constraints y todo tipo de opciones.
Dos limitaciones de MQLite son:
Es imposible escribir una cláusula "A o B" para dos claves distintas.
[{"a:age >":20,"b:name in"["a","b","c"]}]
Algunas directivas, en particular "__limit__", son sensibles al orden.
[{"name":null,"__sort__":"age","__limit__":3}]
Esta última búsqueda ordenará PRIMERO y limitará después en lugar de hacerlo al contrario.
En su API, MQLite usa OrderedDict para asegurarse de mantener el orden correcto en cada
búsqueda (dado que los diccionarios de Python no garantizan un orden concreto).
Compatibilidad con Python
Aunque MQLite está pensado para JSON, puede buscar en prácticamente cualquier cosa iterable
de Python. Para hacerlo, basta con usar el objeto "Pattern" de la API en lugar de "JSONPattern".
Como añadido, MQLite incluye una pequeña shell para poder experimentar con distintas
búsquedas de manera rápida, explorar datasets, etc... También hay una suite de tests disponible.
Hace unos 4 meses (concretamente el 30 de Junio) terminé dir,
un pequeño juego programado en Lua, utilizando Love2D como motor.
Este post no es un postmortem acerca de dir, aunque no descarto escribir
más posts sobre los proyectos que he hecho después de GaGa.
Este post va de la que es probablemente la mejor partida que se ha
jugado en dir hasta el momento. La siguiente screenshot me la envió mi
pareja por email en plan "te dije que lo haría":
Si no has jugado nunca a dir, es probable que la foto no te diga gran
cosa. En dir, es realmente difícil alcanzar el ranking "grandmaster".
Hay de hacer un combo de 25x, llegar al nivel 20 y hacer más de un
millón de puntos en la misma partida.
Si te atreves a intentarlo, dir es gratuito y puedes descargarlo aquí.
Hace unos días me encontré con un post sobre GaGa en una página
web llamada Gorgeous Machine. Estas cosas siempre hacen sonreír.
Que otros hablen bien de tus proyectos es un buen alimento para el ego.
En otro orden de cosas, he terminado MQLite, un programa (y librería)
escrito en Python para hacer pattern-matching al estilo MQL pero
dirigido a JSON. Irónicamente, Freebase cerrará su web y APIs en junio
de este año, migrando todos sus datos a Wikidata.
GaGa es un pequeño reproductor de radios online para Windows, escrito en C#.
Es parecido a RadioTray para Linux, en el sentido en que se integra con
el área de notificación del sistema.
Tiene esta pinta:
Arquitectura
Con unas 3000 líneas de código en 18 archivos, GaGa es bastante más grande
que la mayoría de proyectos que tengo en Github (MultiHash por ejemplo
tiene unas 400 líneas). ¿Cómo organizarlo?.
Las pautas que seguí fueron las siguientes:
Cada parte del programa ha de ser lo más independiente posible
de las demás, de modo que se pueda sustituir si fuese necesario
o razonar sobre ella por separado.
Si algo es útil para otros programas, lo escribiré como una librería
aparte de GaGa. Cuando esté terminado, el programa final será el hilo
conductor que una todos los componentes entre si.
Y este fue el resultado:
Controls: ToolStripAeroRenderer permite renderizar un menú contextual
donde el item seleccionado tiene el mismo color que Aero en Windows,
mientras que ToolStripLabeledTrackBar es un control que uso para los
sliders de balance y volumen.
Libraries: Dos librerías, mINI, para leer archivos INI y LowKey
para poder tomar el control de teclas multimedia a bajo nivel.
NotifyIconPlayer: Implementa el reproductor, basado en el motor de
Windows Media Player. Toma control del icono en el tray para mostrar
su estado actual (por ejemplo, "buffering") o posibles errores.
StreamsFile: Lee archivos INI y añade sus stream a un menú contextual
recargándolo automáticamente cuando sea necesario.
Finalmente, GaGa (GaGa.cs y GaGaSettings.cs) solo implementa el menú y la
lógica de los eventos que se derivan de él, como reproducir una radio al hacer
click en el stream correspondiente. Sirve para unir todo lo anterior.
Curiosidades del diseño:
Todas las excepciones se controlan a nivel de GaGa.cs en lugar de en cada
componente. Es el punto más cercano al usuario, donde es más fácil determinar
qué hacer con cada error (normalmente, mostrar un mensaje).
Los elementos gráficos son: el menú, icono, tooltip al dejar el ratón sobre
el icono y los posibles mensajes sobre el icono. Que la clase Player.cs tome
el control de todos menos el menú simplifica enormemente el código de GaGa.cs
Los métodos que exponen todas las clases hacia GaGa.cs son los mínimos
posibles. Por ejemplo, StreamsFile solo expone LoadTo() (cargar el INI en un
menú) y MustReload() (determina si el INI ha cambiado y necesita recargarse).
Diría que la lección más importante en este punto es: organiza el programa de
modo que necesites tener en tu cabeza el mínimo contenido posible para poder
analizar su funcionamiento.
mINI y LowKey
mINI es una pequeña librería que sirve para leer archivos INI. Es curioso
que aunque el formato INI sea predominante en Windows, .NET no tenga nada
en la librería standard para usarlo.
Lo más interesante de mINI es que no guarda dato alguno del archivo que está
leyendo. Es una clase abstracta con métodos como OnSection(String section)
que las subclases implementan para decidir qué hacer con los datos. Este
diseño es el que permite recargar el menú de GaGa con la mínima latencia
posible, dado que no hay estructuras de datos intermedias.
Escribí mINI en un fin de semana y la verdad es que estoy contento con el
resultado. Es simple, práctico, funciona.
LowKey es mucho más complicado. Es una librería que responde a pulsaciones
de teclas desde cualquier aplicación utilizando hooks a bajo nivel.
Aunque su código es relativamente corto, hay partes en las que es fácil
cometer un error.
Dos ejemplos:
Para poder responder a cada pulsación sin bloquear el thread actual,
uso BeginInvoke en el Dispatcher del thread que creó la instancia
de LowKey. Esto permite reenviar la pulsación a otras aplicaciones
inmediatamente, sin esperar a que el evento de LowKey se controle,
pero también utilizar LowKey en GUIs (si el thread actual es el de la GUI).
El callback que LowKey usa internamente hacia la API de Windows es estático.
Esto evita que el colector de basura lo colecte.
.NET Framework
C# es, en general, un lenguaje excelente. Un buen ejemplo es el modo en el
que GaGa hace la animación en su icono de "buffering", que tiene esta
pinta:
Implementado así (fragmento):
// iconos que vamos a usar:privatereadonlyIcon[]bufferingIcons;// temporizador para cambiar de icono:privatereadonlyDispatcherTimerbufferingIconTimer;// índice del icono actual en el array bufferingIcons:privateInt32currentBufferingIcon;publicPlayer(NotifyIconicon){...// 300 milisegundos entre icono e icono:bufferingIconTimer=newDispatcherTimer(DispatcherPriority.Background);bufferingIconTimer.Interval=TimeSpan.FromMilliseconds(300);bufferingIconTimer.Tick+=OnBufferingIconTimerTick;currentBufferingIcon=0;}privatevoidOnBufferingIconTimerTick(Objectsender,EventArgse){// cambiar icono:notifyIcon.Icon=bufferingIcons[currentBufferingIcon];// ir al siguiente icono o volver el primero si es necesario:currentBufferingIcon++;if(currentBufferingIcon==bufferingIcons.Length){currentBufferingIcon=0;}}
Un simple DispatcherTimer que se ejecuta en el thread actual con prioridad
lo más baja posible (dado que no es algo que requiera precisión). Ojalá todos
los lenguajes tuviesen algo similar.
Por desgracia, no puedo decir lo mismo de Windows Forms o de otras partes
de la librería standard. Aunque sobre el papel es genial, en la práctica hay
comportamientos extraños y bugs que hacen su uso incómodo y que llevan
fácilmente a errores por parte del programador.
Algunos ejemplos (no exhaustivo):
Cuando en un menú (de ContextMenuStrip) hay un item antes de un submenú,
a veces el submenú no se abre automáticamente al pasar el ratón por encima.
Esto no es un problema en casi ninguna aplicación porque normalmente los
submenús están ordenados primero. GaGa respeta el orden que el usuario
establece en el archivo INI.
El reproductor (MediaPlayer, de System.Windows.Media) continúa bajando
datos de streams online tras llamar al método Stop(), es necesario ejecutar
Close() también. Ejecutar Close() resetea el volumen y balance a su valor
por defecto.
Pulsar la tecla alt cierra automáticamente un menú contextual.
ContextMenu (no ContextMenuStrip) guarda su anchura en algún tipo de caché.
Es necesario borrar y recrear el menú entero si se desea que su contenido
sea dinámico o la anchura del menú es incorrecta.
Ejecutar Clear() para limpiar un ContextMenuStrip es insuficiente.
Hace falta llamar a Dispose() para borrar todos los ToolStripMenuItem
o controles que este contenga.
File.GetLastWriteTimeUtc devuelve 12:00, 1 de Enero de 1601 cuando un
archivo no existe. A pesar de ello, puede lanzar 5 tipos de excepciones
distintas cuando el archivo existe pero no hay permisos para acceder
a él o casos similares.
No hay modo standard de abrir un ContextMenuStrip en el punto exacto donde
está el ratón al hacer click, tal como se muestra automáticamente al hacer
click derecho en el icono. Es necesario acceder a un método privado:
ShowInTaskBar (usando Reflection) para poder hacerlo.
Conclusiones
Estoy muy satisfecho con GaGa.
Casi todos mis programas son utilidades de consola diseñadas para usuarios
avanzados (o para programadores), pero GaGa es útil para cualquier persona con
un ordenador. Ha sido un reto interesante.
Además, una vez conseguí escuchar Radio GaGa de Queen en el propio GaGa
(en la radio KissFM), lo que no tiene precio.